From fc62b73a94a4b897635fa624a616b84a079435af Mon Sep 17 00:00:00 2001 From: mblos Date: Fri, 17 Apr 2026 15:12:44 +0200 Subject: [PATCH 1/2] fix: sync only CRs for which Cortex creates reservations --- .../reservations/commitments/syncer.go | 10 ++ .../commitments/syncer_monitor.go | 2 + .../commitments/syncer_monitor_test.go | 1 + .../reservations/commitments/syncer_test.go | 96 +++++++++++++++++++ 4 files changed, 109 insertions(+) diff --git a/internal/scheduling/reservations/commitments/syncer.go b/internal/scheduling/reservations/commitments/syncer.go index c304369df..91708c848 100644 --- a/internal/scheduling/reservations/commitments/syncer.go +++ b/internal/scheduling/reservations/commitments/syncer.go @@ -110,6 +110,16 @@ func (s *Syncer) getCommitmentStates(ctx context.Context, log logr.Logger, flavo continue } + // Only process commitments that are active (confirmed or guaranteed). + // planned/pending are not yet accepted by Cortex; superseded/expired are done. + if commitment.Status != "confirmed" && commitment.Status != "guaranteed" { + log.Info("skipping non-active commitment", "id", id, "status", commitment.Status) + if s.monitor != nil { + s.monitor.RecordCommitmentSkipped(SkipReasonNonActive) + } + continue + } + // Extract flavor group name from resource name (validates format: hw_version__ram) flavorGroupName, err := getFlavorGroupNameFromResource(commitment.ResourceName) if err != nil { diff --git a/internal/scheduling/reservations/commitments/syncer_monitor.go b/internal/scheduling/reservations/commitments/syncer_monitor.go index e597f5884..853518f81 100644 --- a/internal/scheduling/reservations/commitments/syncer_monitor.go +++ b/internal/scheduling/reservations/commitments/syncer_monitor.go @@ -14,6 +14,7 @@ const ( SkipReasonInvalidResource = "invalid_resource_name" SkipReasonEmptyUUID = "empty_uuid" SkipReasonNonCompute = "non_compute" + SkipReasonNonActive = "non_active" ) // SyncerMonitor provides metrics for the commitment syncer. @@ -77,6 +78,7 @@ func NewSyncerMonitor() *SyncerMonitor { SkipReasonInvalidResource, SkipReasonEmptyUUID, SkipReasonNonCompute, + SkipReasonNonActive, } { m.commitmentsSkipped.WithLabelValues(reason) } diff --git a/internal/scheduling/reservations/commitments/syncer_monitor_test.go b/internal/scheduling/reservations/commitments/syncer_monitor_test.go index 2e9c65029..853524a70 100644 --- a/internal/scheduling/reservations/commitments/syncer_monitor_test.go +++ b/internal/scheduling/reservations/commitments/syncer_monitor_test.go @@ -100,6 +100,7 @@ func TestSyncerMonitor_SkipReasonsPreInitialized(t *testing.T) { SkipReasonInvalidResource, SkipReasonEmptyUUID, SkipReasonNonCompute, + SkipReasonNonActive, } { if !presentReasons[reason] { t.Errorf("skip reason %q not pre-initialized in commitments_skipped_total", reason) diff --git a/internal/scheduling/reservations/commitments/syncer_test.go b/internal/scheduling/reservations/commitments/syncer_test.go index b09115453..e4bf6e841 100644 --- a/internal/scheduling/reservations/commitments/syncer_test.go +++ b/internal/scheduling/reservations/commitments/syncer_test.go @@ -234,6 +234,7 @@ func TestSyncer_SyncReservations_InstanceCommitments(t *testing.T) { AvailabilityZone: "az1", Amount: 2, // 2 multiples of smallest flavor (2 * 1024MB = 2048MB total) Unit: "", + Status: "confirmed", ProjectID: "test-project-1", DomainID: "test-domain-1", }, @@ -370,6 +371,7 @@ func TestSyncer_SyncReservations_UpdateExisting(t *testing.T) { AvailabilityZone: "az1", Amount: 1, Unit: "", + Status: "confirmed", ProjectID: "new-project", DomainID: "new-domain", }, @@ -472,6 +474,7 @@ func TestSyncer_SyncReservations_UnitMismatch(t *testing.T) { AvailabilityZone: "az1", Amount: 2, Unit: "2048 MiB", // Mismatched unit - should be "1024 MiB" + Status: "confirmed", ProjectID: "test-project", DomainID: "test-domain", }, @@ -556,6 +559,7 @@ func TestSyncer_SyncReservations_UnitMatch(t *testing.T) { AvailabilityZone: "az1", Amount: 2, Unit: "1024 MiB", // Correct unit matching smallest flavor + Status: "confirmed", ProjectID: "test-project", DomainID: "test-domain", }, @@ -636,6 +640,7 @@ func TestSyncer_SyncReservations_EmptyUUID(t *testing.T) { AvailabilityZone: "az1", Amount: 1, Unit: "", + Status: "confirmed", ProjectID: "test-project", DomainID: "test-domain", }, @@ -686,3 +691,94 @@ func TestSyncer_SyncReservations_EmptyUUID(t *testing.T) { t.Errorf("Expected 0 reservations due to empty UUID, got %d", len(reservations.Items)) } } + +func TestSyncer_SyncReservations_StatusFilter(t *testing.T) { + scheme := runtime.NewScheme() + if err := v1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("Failed to add scheme: %v", err) + } + + flavorGroupsKnowledge := createFlavorGroupKnowledge(t, map[string]FlavorGroupData{ + "test_group_v1": { + LargestFlavorName: "test-flavor", + LargestFlavorVCPUs: 2, + LargestFlavorMemoryMB: 1024, + SmallestFlavorName: "test-flavor", + SmallestFlavorVCPUs: 2, + SmallestFlavorMemoryMB: 1024, + }, + }) + + tests := []struct { + name string + status string + expectReservation bool + }{ + {"confirmed is processed", "confirmed", true}, + {"guaranteed is processed", "guaranteed", true}, + {"planned is skipped", "planned", false}, + {"pending is skipped", "pending", false}, + {"superseded is skipped", "superseded", false}, + {"expired is skipped", "expired", false}, + {"empty status is skipped", "", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(flavorGroupsKnowledge). + Build() + + mockCommitments := []Commitment{ + { + ID: 1, + UUID: "test-uuid-status-filter", + ServiceType: "compute", + ResourceName: "hw_version_test_group_v1_ram", + AvailabilityZone: "az1", + Amount: 1, + Status: tc.status, + ProjectID: "test-project", + DomainID: "test-domain", + }, + } + + monitor := NewSyncerMonitor() + mockClient := &mockCommitmentsClient{ + listCommitmentsByIDFunc: func(ctx context.Context, projects ...Project) (map[string]Commitment, error) { + result := make(map[string]Commitment) + for _, c := range mockCommitments { + result[c.UUID] = c + } + return result, nil + }, + listProjectsFunc: func(ctx context.Context) ([]Project, error) { + return []Project{{ID: "test-project", DomainID: "test-domain"}}, nil + }, + } + + syncer := &Syncer{ + CommitmentsClient: mockClient, + Client: k8sClient, + monitor: monitor, + } + + if err := syncer.SyncReservations(context.Background()); err != nil { + t.Fatalf("SyncReservations() error = %v", err) + } + + var reservations v1alpha1.ReservationList + if err := k8sClient.List(context.Background(), &reservations); err != nil { + t.Fatalf("Failed to list reservations: %v", err) + } + + if tc.expectReservation && len(reservations.Items) == 0 { + t.Errorf("status=%q: expected reservation to be created, got none", tc.status) + } + if !tc.expectReservation && len(reservations.Items) != 0 { + t.Errorf("status=%q: expected no reservation, got %d", tc.status, len(reservations.Items)) + } + }) + } +} From 1548adb430e3ab9158dfe2f105a87ad799aa3b34 Mon Sep 17 00:00:00 2001 From: mblos Date: Tue, 21 Apr 2026 09:10:43 +0200 Subject: [PATCH 2/2] fix: refactor commitments package --- cmd/manager/main.go | 3 +- .../change_commitments.go} | 23 +- .../change_commitments_metrics.go} | 2 +- .../change_commitments_monitor.go} | 2 +- .../change_commitments_monitor_test.go} | 2 +- .../change_commitments_test.go} | 15 +- .../commitments/{api.go => api/handler.go} | 34 +- .../commitments/{api_info.go => api/info.go} | 13 +- .../info_monitor.go} | 2 +- .../{api_info_test.go => api/info_test.go} | 2 +- .../{api_quota.go => api/quota.go} | 4 +- .../report_capacity.go} | 7 +- .../report_capacity_monitor.go} | 2 +- .../report_capacity_test.go} | 13 +- .../report_usage.go} | 7 +- .../report_usage_monitor.go} | 2 +- .../report_usage_test.go} | 13 +- .../commitments/{ => api}/usage_test.go | 470 +----------------- .../reservations/commitments/state.go | 41 +- .../reservations/commitments/state_test.go | 8 +- .../reservations/commitments/syncer.go | 2 +- .../reservations/commitments/usage.go | 134 +++++ .../reservations/commitments/usage_db.go | 89 ---- .../commitments/usage_internals_test.go | 426 ++++++++++++++++ 24 files changed, 657 insertions(+), 659 deletions(-) rename internal/scheduling/reservations/commitments/{api_change_commitments.go => api/change_commitments.go} (94%) rename internal/scheduling/reservations/commitments/{api_change_commitments_metrics.go => api/change_commitments_metrics.go} (98%) rename internal/scheduling/reservations/commitments/{api_change_commitments_monitor.go => api/change_commitments_monitor.go} (99%) rename internal/scheduling/reservations/commitments/{api_change_commitments_monitor_test.go => api/change_commitments_monitor_test.go} (99%) rename internal/scheduling/reservations/commitments/{api_change_commitments_test.go => api/change_commitments_test.go} (99%) rename internal/scheduling/reservations/commitments/{api.go => api/handler.go} (72%) rename internal/scheduling/reservations/commitments/{api_info.go => api/info.go} (93%) rename internal/scheduling/reservations/commitments/{api_info_monitor.go => api/info_monitor.go} (98%) rename internal/scheduling/reservations/commitments/{api_info_test.go => api/info_test.go} (99%) rename internal/scheduling/reservations/commitments/{api_quota.go => api/quota.go} (93%) rename internal/scheduling/reservations/commitments/{api_report_capacity.go => api/report_capacity.go} (91%) rename internal/scheduling/reservations/commitments/{api_report_capacity_monitor.go => api/report_capacity_monitor.go} (98%) rename internal/scheduling/reservations/commitments/{api_report_capacity_test.go => api/report_capacity_test.go} (96%) rename internal/scheduling/reservations/commitments/{api_report_usage.go => api/report_usage.go} (94%) rename internal/scheduling/reservations/commitments/{api_report_usage_monitor.go => api/report_usage_monitor.go} (98%) rename internal/scheduling/reservations/commitments/{api_report_usage_test.go => api/report_usage_test.go} (98%) rename internal/scheduling/reservations/commitments/{ => api}/usage_test.go (57%) delete mode 100644 internal/scheduling/reservations/commitments/usage_db.go create mode 100644 internal/scheduling/reservations/commitments/usage_internals_test.go diff --git a/cmd/manager/main.go b/cmd/manager/main.go index 073c5f195..4c390f5a8 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -57,6 +57,7 @@ import ( "github.com/cobaltcore-dev/cortex/internal/scheduling/pods" "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations" "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations/commitments" + commitmentsapi "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations/commitments/api" "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations/failover" "github.com/cobaltcore-dev/cortex/pkg/conf" "github.com/cobaltcore-dev/cortex/pkg/monitoring" @@ -364,7 +365,7 @@ func main() { if commitmentsConfig.DatasourceName != "" { commitmentsUsageDB = commitments.NewDBUsageClient(multiclusterClient, commitmentsConfig.DatasourceName) } - commitmentsAPI := commitments.NewAPIWithConfig(multiclusterClient, commitmentsConfig, commitmentsUsageDB) + commitmentsAPI := commitmentsapi.NewAPIWithConfig(multiclusterClient, commitmentsConfig, commitmentsUsageDB) commitmentsAPI.Init(mux, metrics.Registry, ctrl.Log.WithName("commitments-api")) if slices.Contains(mainConfig.EnabledControllers, "nova-pipeline-controllers") { diff --git a/internal/scheduling/reservations/commitments/api_change_commitments.go b/internal/scheduling/reservations/commitments/api/change_commitments.go similarity index 94% rename from internal/scheduling/reservations/commitments/api_change_commitments.go rename to internal/scheduling/reservations/commitments/api/change_commitments.go index 8fc4c075f..e076e41c9 100644 --- a/internal/scheduling/reservations/commitments/api_change_commitments.go +++ b/internal/scheduling/reservations/commitments/api/change_commitments.go @@ -1,7 +1,7 @@ // Copyright SAP SE // SPDX-License-Identifier: Apache-2.0 -package commitments +package api import ( "context" @@ -15,6 +15,7 @@ import ( "github.com/cobaltcore-dev/cortex/api/v1alpha1" "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations" + commitments "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations/commitments" "github.com/go-logr/logr" "github.com/google/uuid" "github.com/sapcc/go-api-declarations/liquid" @@ -69,7 +70,7 @@ func (api *HTTPAPI) HandleChangeCommitments(w http.ResponseWriter, r *http.Reque defer api.changeMutex.Unlock() ctx := reservations.WithGlobalRequestID(context.Background(), "committed-resource-"+requestID) - logger := LoggerFromContext(ctx).WithValues("component", "api", "endpoint", "/commitments/v1/change-commitments") + logger := commitments.LoggerFromContext(ctx).WithValues("component", "api", "endpoint", "/commitments/v1/change-commitments") // Only accept POST method if r.Method != http.MethodPost { @@ -131,7 +132,7 @@ func (api *HTTPAPI) HandleChangeCommitments(w http.ResponseWriter, r *http.Reque } func (api *HTTPAPI) processCommitmentChanges(ctx context.Context, w http.ResponseWriter, logger logr.Logger, req liquid.CommitmentChangeRequest, resp *liquid.CommitmentChangeResponse) error { - manager := NewReservationManager(api.client) + manager := commitments.NewReservationManager(api.client) requireRollback := false failedCommitments := make(map[string]string) // commitmentUUID to reason for failure, for better response messages in case of rollback creatorRequestID := reservations.GlobalRequestIDFromContext(ctx) @@ -159,7 +160,7 @@ func (api *HTTPAPI) processCommitmentChanges(ctx context.Context, w http.Respons return errors.New("version mismatch") } - statesBefore := make(map[string]*CommitmentState) // map of commitmentID to existing state for rollback + statesBefore := make(map[string]*commitments.CommitmentState) // map of commitmentID to existing state for rollback var reservationsToWatch []v1alpha1.Reservation if req.DryRun { @@ -173,7 +174,7 @@ ProcessLoop: for _, resourceName := range sortedKeys(projectChanges.ByResource) { resourceChanges := projectChanges.ByResource[resourceName] // Validate resource name pattern (instances_group_*) - flavorGroupName, err := getFlavorGroupNameFromResource(string(resourceName)) + flavorGroupName, err := commitments.GetFlavorGroupNameFromResource(string(resourceName)) if err != nil { resp.RejectionReason = fmt.Sprintf("project with unknown resource name %s: %v", projectID, err) requireRollback = true @@ -189,8 +190,8 @@ ProcessLoop: } // Reject commitments for flavor groups that don't accept CRs - if !FlavorGroupAcceptsCommitments(&flavorGroup) { - resp.RejectionReason = FlavorGroupCommitmentRejectionReason(&flavorGroup) + if !commitments.FlavorGroupAcceptsCommitments(&flavorGroup) { + resp.RejectionReason = commitments.FlavorGroupCommitmentRejectionReason(&flavorGroup) requireRollback = true break ProcessLoop } @@ -221,16 +222,16 @@ ProcessLoop: } } - var stateBefore *CommitmentState + var stateBefore *commitments.CommitmentState if len(existing_reservations.Items) == 0 { - stateBefore = &CommitmentState{ + stateBefore = &commitments.CommitmentState{ CommitmentUUID: string(commitment.UUID), ProjectID: string(projectID), FlavorGroupName: flavorGroupName, TotalMemoryBytes: 0, } } else { - stateBefore, err = FromReservations(existing_reservations.Items) + stateBefore, err = commitments.FromReservations(existing_reservations.Items) if err != nil { failedCommitments[string(commitment.UUID)] = "failed to parse existing commitment reservations" logger.Info("failed to get existing state for commitment", "commitmentUUID", commitment.UUID, "error", err) @@ -241,7 +242,7 @@ ProcessLoop: statesBefore[string(commitment.UUID)] = stateBefore // get desired state - stateDesired, err := FromChangeCommitmentTargetState(commitment, string(projectID), flavorGroupName, flavorGroup, string(req.AZ)) + stateDesired, err := commitments.FromChangeCommitmentTargetState(commitment, string(projectID), flavorGroupName, flavorGroup, string(req.AZ)) if err != nil { failedCommitments[string(commitment.UUID)] = err.Error() logger.Info("failed to get desired state for commitment", "commitmentUUID", commitment.UUID, "error", err) diff --git a/internal/scheduling/reservations/commitments/api_change_commitments_metrics.go b/internal/scheduling/reservations/commitments/api/change_commitments_metrics.go similarity index 98% rename from internal/scheduling/reservations/commitments/api_change_commitments_metrics.go rename to internal/scheduling/reservations/commitments/api/change_commitments_metrics.go index edb09b5da..2c9562ee8 100644 --- a/internal/scheduling/reservations/commitments/api_change_commitments_metrics.go +++ b/internal/scheduling/reservations/commitments/api/change_commitments_metrics.go @@ -1,7 +1,7 @@ // Copyright SAP SE // SPDX-License-Identifier: Apache-2.0 -package commitments +package api import ( "strconv" diff --git a/internal/scheduling/reservations/commitments/api_change_commitments_monitor.go b/internal/scheduling/reservations/commitments/api/change_commitments_monitor.go similarity index 99% rename from internal/scheduling/reservations/commitments/api_change_commitments_monitor.go rename to internal/scheduling/reservations/commitments/api/change_commitments_monitor.go index d8522008b..6b1a5c31d 100644 --- a/internal/scheduling/reservations/commitments/api_change_commitments_monitor.go +++ b/internal/scheduling/reservations/commitments/api/change_commitments_monitor.go @@ -1,7 +1,7 @@ // Copyright SAP SE // SPDX-License-Identifier: Apache-2.0 -package commitments +package api import ( "github.com/prometheus/client_golang/prometheus" diff --git a/internal/scheduling/reservations/commitments/api_change_commitments_monitor_test.go b/internal/scheduling/reservations/commitments/api/change_commitments_monitor_test.go similarity index 99% rename from internal/scheduling/reservations/commitments/api_change_commitments_monitor_test.go rename to internal/scheduling/reservations/commitments/api/change_commitments_monitor_test.go index 74fc0c938..a999df7dc 100644 --- a/internal/scheduling/reservations/commitments/api_change_commitments_monitor_test.go +++ b/internal/scheduling/reservations/commitments/api/change_commitments_monitor_test.go @@ -1,7 +1,7 @@ // Copyright SAP SE // SPDX-License-Identifier: Apache-2.0 -package commitments +package api import ( "encoding/json" diff --git a/internal/scheduling/reservations/commitments/api_change_commitments_test.go b/internal/scheduling/reservations/commitments/api/change_commitments_test.go similarity index 99% rename from internal/scheduling/reservations/commitments/api_change_commitments_test.go rename to internal/scheduling/reservations/commitments/api/change_commitments_test.go index 14a39aaf8..deffc91c3 100644 --- a/internal/scheduling/reservations/commitments/api_change_commitments_test.go +++ b/internal/scheduling/reservations/commitments/api/change_commitments_test.go @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 //nolint:unparam,unused // test helper functions have fixed parameters for simplicity -package commitments +package api import ( "bytes" @@ -22,6 +22,7 @@ import ( "github.com/cobaltcore-dev/cortex/api/v1alpha1" "github.com/cobaltcore-dev/cortex/internal/knowledge/extractor/plugins/compute" + commitments "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations/commitments" hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" "github.com/prometheus/client_golang/prometheus" "github.com/sapcc/go-api-declarations/liquid" @@ -444,8 +445,8 @@ func TestCommitmentChangeIntegration(t *testing.T) { CommitmentRequest: newCommitmentRequest("az-a", false, 1234, createCommitment("hw_version_hana_1_ram", "project-A", "uuid-disabled", "confirmed", 2), ), - CustomConfig: func() *Config { - cfg := DefaultConfig() + CustomConfig: func() *commitments.Config { + cfg := commitments.DefaultConfig() cfg.EnableChangeCommitmentsAPI = false return &cfg }(), @@ -498,8 +499,8 @@ func TestCommitmentChangeIntegration(t *testing.T) { createCommitment("hw_version_hana_1_ram", "project-A", "uuid-timeout", "confirmed", 2), ), // With 0ms timeout, the watch will timeout immediately before reservations become ready - CustomConfig: func() *Config { - cfg := DefaultConfig() + CustomConfig: func() *commitments.Config { + cfg := commitments.DefaultConfig() cfg.ChangeAPIWatchReservationsTimeout = 0 * time.Millisecond cfg.ChangeAPIWatchReservationsPollInterval = 100 * time.Millisecond return &cfg @@ -600,7 +601,7 @@ type CommitmentChangeTestCase struct { ExpectedAPIResponse APIResponseExpectation AvailableResources *AvailableResources // If nil, all reservations accepted without checks EnvInfoVersion int64 // Override InfoVersion for version mismatch tests - CustomConfig *Config // Override default config for testing timeout behavior + CustomConfig *commitments.Config // Override default config for testing timeout behavior } // AvailableResources defines available memory per host (MB). @@ -944,7 +945,7 @@ func newCommitmentTestEnv( reservations []*v1alpha1.Reservation, flavorGroups FlavorGroupsKnowledge, resources *AvailableResources, - customConfig *Config, + customConfig *commitments.Config, ) *CommitmentTestEnv { t.Helper() diff --git a/internal/scheduling/reservations/commitments/api.go b/internal/scheduling/reservations/commitments/api/handler.go similarity index 72% rename from internal/scheduling/reservations/commitments/api.go rename to internal/scheduling/reservations/commitments/api/handler.go index e61026b68..f0eb24110 100644 --- a/internal/scheduling/reservations/commitments/api.go +++ b/internal/scheduling/reservations/commitments/api/handler.go @@ -1,45 +1,27 @@ // Copyright SAP SE // SPDX-License-Identifier: Apache-2.0 -package commitments +package api import ( - "context" "net/http" "strings" "sync" + commitments "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations/commitments" "github.com/go-logr/logr" "github.com/prometheus/client_golang/prometheus" + ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" ) -// UsageDBClient is the minimal interface for querying VM usage data from Postgres. -type UsageDBClient interface { - // ListProjectVMs returns all VMs for a project with their flavor data. - ListProjectVMs(ctx context.Context, projectID string) ([]VMRow, error) -} - -// VMRow is the result of a joined server+flavor query from Postgres. -type VMRow struct { - ID string - Name string - Status string - Created string - AZ string - Hypervisor string - FlavorName string - FlavorRAM uint64 - FlavorVCPUs uint64 - FlavorDisk uint64 - FlavorExtras string // JSON string of flavor extra_specs -} +var apiLog = ctrl.Log.WithName("committed-resource") // HTTPAPI implements Limes LIQUID commitment validation endpoints. type HTTPAPI struct { client client.Client - config Config - usageDB UsageDBClient + config commitments.Config + usageDB commitments.UsageDBClient monitor ChangeCommitmentsAPIMonitor usageMonitor ReportUsageAPIMonitor capacityMonitor ReportCapacityAPIMonitor @@ -49,11 +31,11 @@ type HTTPAPI struct { } func NewAPI(client client.Client) *HTTPAPI { - return NewAPIWithConfig(client, DefaultConfig(), nil) + return NewAPIWithConfig(client, commitments.DefaultConfig(), nil) } // NewAPIWithConfig creates an HTTPAPI with the given config and optional usageDB client. -func NewAPIWithConfig(k8sClient client.Client, config Config, usageDB UsageDBClient) *HTTPAPI { +func NewAPIWithConfig(k8sClient client.Client, config commitments.Config, usageDB commitments.UsageDBClient) *HTTPAPI { return &HTTPAPI{ client: k8sClient, config: config, diff --git a/internal/scheduling/reservations/commitments/api_info.go b/internal/scheduling/reservations/commitments/api/info.go similarity index 93% rename from internal/scheduling/reservations/commitments/api_info.go rename to internal/scheduling/reservations/commitments/api/info.go index bd3b3f3cf..6999b38d6 100644 --- a/internal/scheduling/reservations/commitments/api_info.go +++ b/internal/scheduling/reservations/commitments/api/info.go @@ -1,7 +1,7 @@ // Copyright SAP SE // SPDX-License-Identifier: Apache-2.0 -package commitments +package api import ( "context" @@ -14,6 +14,7 @@ import ( "time" "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations" + commitments "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations/commitments" "github.com/go-logr/logr" "github.com/google/uuid" liquid "github.com/sapcc/go-api-declarations/liquid" @@ -38,7 +39,7 @@ func (api *HTTPAPI) HandleInfo(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Request-ID", requestID) ctx := reservations.WithGlobalRequestID(r.Context(), "committed-resource-"+requestID) - logger := LoggerFromContext(ctx).WithValues("component", "api", "endpoint", "/commitments/v1/info") + logger := commitments.LoggerFromContext(ctx).WithValues("component", "api", "endpoint", "/commitments/v1/info") // Only accept GET method if r.Method != http.MethodGet { @@ -113,7 +114,7 @@ func (api *HTTPAPI) buildServiceInfo(ctx context.Context, logger logr.Logger) (l resources := make(map[liquid.ResourceName]liquid.ResourceInfo) for groupName, groupData := range flavorGroups { // Determine if this group accepts commitments (requires fixed RAM/core ratio) - handlesCommitments := FlavorGroupAcceptsCommitments(&groupData) + handlesCommitments := commitments.FlavorGroupAcceptsCommitments(&groupData) // All flavor groups are registered for usage reporting. // Only those with a fixed RAM/core ratio have HandlesCommitments=true. @@ -143,7 +144,7 @@ func (api *HTTPAPI) buildServiceInfo(ctx context.Context, logger logr.Logger) (l } // === 1. RAM Resource === - ramResourceName := liquid.ResourceName(ResourceNameRAM(groupName)) + ramResourceName := liquid.ResourceName(commitments.ResourceNameRAM(groupName)) ramUnit, err := liquid.UnitMebibytes.MultiplyBy(groupData.SmallestFlavor.MemoryMB) if err != nil { // Note: This error only occurs on uint64 overflow, which is unrealistic for memory values @@ -166,7 +167,7 @@ func (api *HTTPAPI) buildServiceInfo(ctx context.Context, logger logr.Logger) (l } // === 2. Cores Resource === - coresResourceName := liquid.ResourceName(ResourceNameCores(groupName)) + coresResourceName := liquid.ResourceName(commitments.ResourceNameCores(groupName)) resources[coresResourceName] = liquid.ResourceInfo{ DisplayName: fmt.Sprintf( "CPU cores (usable by: %s)", @@ -182,7 +183,7 @@ func (api *HTTPAPI) buildServiceInfo(ctx context.Context, logger logr.Logger) (l } // === 3. Instances Resource === - instancesResourceName := liquid.ResourceName(ResourceNameInstances(groupName)) + instancesResourceName := liquid.ResourceName(commitments.ResourceNameInstances(groupName)) resources[instancesResourceName] = liquid.ResourceInfo{ DisplayName: fmt.Sprintf( "instances (usable by: %s)", diff --git a/internal/scheduling/reservations/commitments/api_info_monitor.go b/internal/scheduling/reservations/commitments/api/info_monitor.go similarity index 98% rename from internal/scheduling/reservations/commitments/api_info_monitor.go rename to internal/scheduling/reservations/commitments/api/info_monitor.go index aaae937ef..29bc92474 100644 --- a/internal/scheduling/reservations/commitments/api_info_monitor.go +++ b/internal/scheduling/reservations/commitments/api/info_monitor.go @@ -1,7 +1,7 @@ // Copyright SAP SE // SPDX-License-Identifier: Apache-2.0 -package commitments +package api import ( "github.com/prometheus/client_golang/prometheus" diff --git a/internal/scheduling/reservations/commitments/api_info_test.go b/internal/scheduling/reservations/commitments/api/info_test.go similarity index 99% rename from internal/scheduling/reservations/commitments/api_info_test.go rename to internal/scheduling/reservations/commitments/api/info_test.go index b733a853b..48e12fd2c 100644 --- a/internal/scheduling/reservations/commitments/api_info_test.go +++ b/internal/scheduling/reservations/commitments/api/info_test.go @@ -1,7 +1,7 @@ // Copyright SAP SE // SPDX-License-Identifier: Apache-2.0 -package commitments +package api import ( "encoding/json" diff --git a/internal/scheduling/reservations/commitments/api_quota.go b/internal/scheduling/reservations/commitments/api/quota.go similarity index 93% rename from internal/scheduling/reservations/commitments/api_quota.go rename to internal/scheduling/reservations/commitments/api/quota.go index 184728f39..c77fdf1a6 100644 --- a/internal/scheduling/reservations/commitments/api_quota.go +++ b/internal/scheduling/reservations/commitments/api/quota.go @@ -1,7 +1,7 @@ // Copyright SAP SE // SPDX-License-Identifier: Apache-2.0 -package commitments +package api import ( "net/http" @@ -24,7 +24,7 @@ func (api *HTTPAPI) HandleQuota(w http.ResponseWriter, r *http.Request) { } w.Header().Set("X-Request-ID", requestID) - log := baseLog.WithValues("requestID", requestID, "endpoint", "quota") + log := apiLog.WithValues("requestID", requestID, "endpoint", "quota") if r.Method != http.MethodPut { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) diff --git a/internal/scheduling/reservations/commitments/api_report_capacity.go b/internal/scheduling/reservations/commitments/api/report_capacity.go similarity index 91% rename from internal/scheduling/reservations/commitments/api_report_capacity.go rename to internal/scheduling/reservations/commitments/api/report_capacity.go index 09fc55168..f846fea8e 100644 --- a/internal/scheduling/reservations/commitments/api_report_capacity.go +++ b/internal/scheduling/reservations/commitments/api/report_capacity.go @@ -1,7 +1,7 @@ // Copyright SAP SE // SPDX-License-Identifier: Apache-2.0 -package commitments +package api import ( "encoding/json" @@ -10,6 +10,7 @@ import ( "time" "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations" + commitments "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations/commitments" "github.com/google/uuid" "github.com/sapcc/go-api-declarations/liquid" ) @@ -38,7 +39,7 @@ func (api *HTTPAPI) HandleReportCapacity(w http.ResponseWriter, r *http.Request) } ctx := reservations.WithGlobalRequestID(r.Context(), "committed-resource-"+requestID) - logger := LoggerFromContext(ctx).WithValues("component", "api", "endpoint", "/commitments/v1/report-capacity") + logger := commitments.LoggerFromContext(ctx).WithValues("component", "api", "endpoint", "/commitments/v1/report-capacity") // Only accept POST method if r.Method != http.MethodPost { @@ -58,7 +59,7 @@ func (api *HTTPAPI) HandleReportCapacity(w http.ResponseWriter, r *http.Request) } // Calculate capacity - calculator := NewCapacityCalculator(api.client) + calculator := commitments.NewCapacityCalculator(api.client) report, err := calculator.CalculateCapacity(ctx, req) if err != nil { logger.Error(err, "failed to calculate capacity") diff --git a/internal/scheduling/reservations/commitments/api_report_capacity_monitor.go b/internal/scheduling/reservations/commitments/api/report_capacity_monitor.go similarity index 98% rename from internal/scheduling/reservations/commitments/api_report_capacity_monitor.go rename to internal/scheduling/reservations/commitments/api/report_capacity_monitor.go index d78af6cc7..930d3dcb6 100644 --- a/internal/scheduling/reservations/commitments/api_report_capacity_monitor.go +++ b/internal/scheduling/reservations/commitments/api/report_capacity_monitor.go @@ -1,7 +1,7 @@ // Copyright SAP SE // SPDX-License-Identifier: Apache-2.0 -package commitments +package api import ( "github.com/prometheus/client_golang/prometheus" diff --git a/internal/scheduling/reservations/commitments/api_report_capacity_test.go b/internal/scheduling/reservations/commitments/api/report_capacity_test.go similarity index 96% rename from internal/scheduling/reservations/commitments/api_report_capacity_test.go rename to internal/scheduling/reservations/commitments/api/report_capacity_test.go index b151382cf..8f6029438 100644 --- a/internal/scheduling/reservations/commitments/api_report_capacity_test.go +++ b/internal/scheduling/reservations/commitments/api/report_capacity_test.go @@ -1,7 +1,7 @@ // Copyright SAP SE // SPDX-License-Identifier: Apache-2.0 -package commitments +package api import ( "bytes" @@ -19,6 +19,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "github.com/cobaltcore-dev/cortex/api/v1alpha1" + commitments "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations/commitments" ) func TestHandleReportCapacity(t *testing.T) { @@ -135,7 +136,7 @@ func TestCapacityCalculator(t *testing.T) { WithScheme(scheme). Build() - calculator := NewCapacityCalculator(fakeClient) + calculator := commitments.NewCapacityCalculator(fakeClient) req := liquid.ServiceCapacityRequest{ AllAZs: []liquid.AvailabilityZone{"az-one", "az-two"}, } @@ -157,7 +158,7 @@ func TestCapacityCalculator(t *testing.T) { WithObjects(emptyKnowledge). Build() - calculator := NewCapacityCalculator(fakeClient) + calculator := commitments.NewCapacityCalculator(fakeClient) req := liquid.ServiceCapacityRequest{ AllAZs: []liquid.AvailabilityZone{"az-one", "az-two"}, } @@ -182,7 +183,7 @@ func TestCapacityCalculator(t *testing.T) { WithObjects(flavorGroupKnowledge). Build() - calculator := NewCapacityCalculator(fakeClient) + calculator := commitments.NewCapacityCalculator(fakeClient) req := liquid.ServiceCapacityRequest{ AllAZs: []liquid.AvailabilityZone{"qa-de-1a", "qa-de-1b", "qa-de-1d"}, } @@ -208,7 +209,7 @@ func TestCapacityCalculator(t *testing.T) { WithObjects(flavorGroupKnowledge). Build() - calculator := NewCapacityCalculator(fakeClient) + calculator := commitments.NewCapacityCalculator(fakeClient) req := liquid.ServiceCapacityRequest{AllAZs: []liquid.AvailabilityZone{}} report, err := calculator.CalculateCapacity(context.Background(), req) if err != nil { @@ -233,7 +234,7 @@ func TestCapacityCalculator(t *testing.T) { WithObjects(flavorGroupKnowledge). Build() - calculator := NewCapacityCalculator(fakeClient) + calculator := commitments.NewCapacityCalculator(fakeClient) req1 := liquid.ServiceCapacityRequest{ AllAZs: []liquid.AvailabilityZone{"eu-de-1a", "eu-de-1b"}, diff --git a/internal/scheduling/reservations/commitments/api_report_usage.go b/internal/scheduling/reservations/commitments/api/report_usage.go similarity index 94% rename from internal/scheduling/reservations/commitments/api_report_usage.go rename to internal/scheduling/reservations/commitments/api/report_usage.go index e0d39f81e..d87f7c24a 100644 --- a/internal/scheduling/reservations/commitments/api_report_usage.go +++ b/internal/scheduling/reservations/commitments/api/report_usage.go @@ -1,7 +1,7 @@ // Copyright SAP SE // SPDX-License-Identifier: Apache-2.0 -package commitments +package api import ( "encoding/json" @@ -11,6 +11,7 @@ import ( "strings" "time" + commitments "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations/commitments" "github.com/google/uuid" "github.com/sapcc/go-api-declarations/liquid" ) @@ -32,7 +33,7 @@ func (api *HTTPAPI) HandleReportUsage(w http.ResponseWriter, r *http.Request) { } w.Header().Set("X-Request-ID", requestID) - log := baseLog.WithValues("requestID", requestID, "endpoint", "report-usage") + log := apiLog.WithValues("requestID", requestID, "endpoint", "report-usage") // Check if API is enabled if !api.config.EnableReportUsageAPI { @@ -72,7 +73,7 @@ func (api *HTTPAPI) HandleReportUsage(w http.ResponseWriter, r *http.Request) { } // Use UsageCalculator to build usage report - calculator := NewUsageCalculator(api.client, api.usageDB) + calculator := commitments.NewUsageCalculator(api.client, api.usageDB) report, err := calculator.CalculateUsage(r.Context(), log, projectID, req.AllAZs) if err != nil { log.Error(err, "failed to calculate usage report", "projectID", projectID) diff --git a/internal/scheduling/reservations/commitments/api_report_usage_monitor.go b/internal/scheduling/reservations/commitments/api/report_usage_monitor.go similarity index 98% rename from internal/scheduling/reservations/commitments/api_report_usage_monitor.go rename to internal/scheduling/reservations/commitments/api/report_usage_monitor.go index bbcaaaf86..bbb4ce488 100644 --- a/internal/scheduling/reservations/commitments/api_report_usage_monitor.go +++ b/internal/scheduling/reservations/commitments/api/report_usage_monitor.go @@ -1,7 +1,7 @@ // Copyright SAP SE // SPDX-License-Identifier: Apache-2.0 -package commitments +package api import ( "github.com/prometheus/client_golang/prometheus" diff --git a/internal/scheduling/reservations/commitments/api_report_usage_test.go b/internal/scheduling/reservations/commitments/api/report_usage_test.go similarity index 98% rename from internal/scheduling/reservations/commitments/api_report_usage_test.go rename to internal/scheduling/reservations/commitments/api/report_usage_test.go index 5259af6ca..4cafdc213 100644 --- a/internal/scheduling/reservations/commitments/api_report_usage_test.go +++ b/internal/scheduling/reservations/commitments/api/report_usage_test.go @@ -1,7 +1,7 @@ // Copyright SAP SE // SPDX-License-Identifier: Apache-2.0 -package commitments +package api import ( "bytes" @@ -18,6 +18,7 @@ import ( "github.com/cobaltcore-dev/cortex/api/v1alpha1" "github.com/cobaltcore-dev/cortex/internal/knowledge/extractor/plugins/compute" + commitments "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations/commitments" hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" "github.com/prometheus/client_golang/prometheus" "github.com/sapcc/go-api-declarations/liquid" @@ -471,17 +472,17 @@ type ExpectedVMUsage struct { // ============================================================================ type mockUsageDBClient struct { - rows map[string][]VMRow // projectID -> rows + rows map[string][]commitments.VMRow // projectID -> rows err error } func newMockUsageDBClient() *mockUsageDBClient { return &mockUsageDBClient{ - rows: make(map[string][]VMRow), + rows: make(map[string][]commitments.VMRow), } } -func (m *mockUsageDBClient) ListProjectVMs(_ context.Context, projectID string) ([]VMRow, error) { +func (m *mockUsageDBClient) ListProjectVMs(_ context.Context, projectID string) ([]commitments.VMRow, error) { if m.err != nil { return nil, m.err } @@ -496,7 +497,7 @@ func (m *mockUsageDBClient) addVM(vm *TestVMUsage) { extraSpecs["hw_video:ram_max_mb"] = strconv.FormatUint(*vm.Flavor.VideoRAMMiB, 10) } extrasJSON, _ := json.Marshal(extraSpecs) //nolint:errcheck // test helper, always valid - row := VMRow{ + row := commitments.VMRow{ ID: vm.UUID, Name: vm.UUID, Status: "ACTIVE", @@ -579,7 +580,7 @@ func newUsageTestEnv( } // Create API with mock DB client - api := NewAPIWithConfig(k8sClient, DefaultConfig(), dbClient) + api := NewAPIWithConfig(k8sClient, commitments.DefaultConfig(), dbClient) mux := http.NewServeMux() registry := prometheus.NewRegistry() api.Init(mux, registry, log.Log) diff --git a/internal/scheduling/reservations/commitments/usage_test.go b/internal/scheduling/reservations/commitments/api/usage_test.go similarity index 57% rename from internal/scheduling/reservations/commitments/usage_test.go rename to internal/scheduling/reservations/commitments/api/usage_test.go index 8019c5d13..71fed90bb 100644 --- a/internal/scheduling/reservations/commitments/usage_test.go +++ b/internal/scheduling/reservations/commitments/api/usage_test.go @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 //nolint:unparam,errcheck // test helper functions have fixed parameters for simplicity -package commitments +package api import ( "context" @@ -13,6 +13,7 @@ import ( "github.com/cobaltcore-dev/cortex/api/v1alpha1" "github.com/cobaltcore-dev/cortex/internal/knowledge/extractor/plugins/compute" + commitments "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations/commitments" hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" "github.com/sapcc/go-api-declarations/liquid" "k8s.io/apimachinery/pkg/api/resource" @@ -40,7 +41,7 @@ func TestUsageCalculator_CalculateUsage(t *testing.T) { tests := []struct { name string projectID string - vms []VMRow + vms []commitments.VMRow reservations []*v1alpha1.Reservation allAZs []liquid.AvailabilityZone expectedUsage map[string]uint64 // resourceName -> usage @@ -48,7 +49,7 @@ func TestUsageCalculator_CalculateUsage(t *testing.T) { { name: "empty project", projectID: "project-empty", - vms: []VMRow{}, + vms: []commitments.VMRow{}, reservations: []*v1alpha1.Reservation{}, allAZs: []liquid.AvailabilityZone{"az-a"}, expectedUsage: map[string]uint64{ @@ -58,7 +59,7 @@ func TestUsageCalculator_CalculateUsage(t *testing.T) { { name: "single VM with commitment", projectID: "project-A", - vms: []VMRow{ + vms: []commitments.VMRow{ { ID: "vm-001", Name: "vm-001", Status: "ACTIVE", AZ: "az-a", @@ -80,7 +81,7 @@ func TestUsageCalculator_CalculateUsage(t *testing.T) { { name: "VM without matching commitment - PAYG", projectID: "project-B", - vms: []VMRow{ + vms: []commitments.VMRow{ { ID: "vm-002", Name: "vm-002", Status: "ACTIVE", AZ: "az-a", @@ -122,13 +123,13 @@ func TestUsageCalculator_CalculateUsage(t *testing.T) { // Setup mock Nova client dbClient := &mockUsageDBClient{ - rows: map[string][]VMRow{ + rows: map[string][]commitments.VMRow{ tt.projectID: tt.vms, }, } // Create calculator and run - calc := NewUsageCalculator(k8sClient, dbClient) + calc := commitments.NewUsageCalculator(k8sClient, dbClient) logger := log.FromContext(ctx) report, err := calc.CalculateUsage(ctx, logger, tt.projectID, tt.allAZs) if err != nil { @@ -161,314 +162,6 @@ func TestUsageCalculator_CalculateUsage(t *testing.T) { } } -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") - - // Status at top level - if attrs["status"] != "ACTIVE" { - t.Errorf("status = %v, expected ACTIVE", attrs["status"]) - } - - // metadata, tags and os_type are not included (not in Postgres cache) - for _, absent := range []string{"metadata", "tags", "os_type"} { - if _, present := attrs[absent]; present { - t.Errorf("%s must not appear in output (not available from Postgres cache)", absent) - } - } - - // Flavor is now nested - flavor, ok := attrs["flavor"].(map[string]any) - if !ok { - t.Errorf("flavor is not map[string]any: %T", attrs["flavor"]) - } else { - if flavor["name"] != "m1.large" { - t.Errorf("flavor.name = %v, expected m1.large", flavor["name"]) - } - if flavor["vcpu"] != uint64(16) { - t.Errorf("flavor.vcpu = %v, expected 16", flavor["vcpu"]) - } - if flavor["ram_mib"] != uint64(4096) { - t.Errorf("flavor.ram_mib = %v, expected 4096", flavor["ram_mib"]) - } - if flavor["disk_gib"] != uint64(100) { - t.Errorf("flavor.disk_gib = %v, expected 100", flavor["disk_gib"]) - } - } - - // Commitment ID - 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"]) - } - }) - - t.Run("with video RAM - video_ram_mib present", func(t *testing.T) { - vram := uint64(16) - vmWithVRAM := vm - vmWithVRAM.VideoRAMMiB = &vram - attrs := buildVMAttributes(vmWithVRAM, "") - - flavor, ok := attrs["flavor"].(map[string]any) - if !ok { - t.Fatalf("flavor is not map[string]any: %T", attrs["flavor"]) - } - if flavor["video_ram_mib"] != uint64(16) { - t.Errorf("flavor.video_ram_mib = %v, expected 16", flavor["video_ram_mib"]) - } - if _, present := flavor["hw_version"]; present { - t.Errorf("flavor.hw_version must not appear in output") - } - }) - - t.Run("without video RAM - video_ram_mib absent", func(t *testing.T) { - attrs := buildVMAttributes(vm, "") // vm.VideoRAMMiB is nil - - flavor, ok := attrs["flavor"].(map[string]any) - if !ok { - t.Fatalf("flavor is not map[string]any: %T", attrs["flavor"]) - } - if _, present := flavor["video_ram_mib"]; present { - t.Errorf("flavor.video_ram_mib should be absent when VideoRAMMiB is nil, got %v", flavor["video_ram_mib"]) - } - }) -} - -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() @@ -481,7 +174,7 @@ func TestUsageCalculator_ExpiredAndFutureCommitments(t *testing.T) { tests := []struct { name string projectID string - vms []VMRow + vms []commitments.VMRow reservations []*v1alpha1.Reservation allAZs []liquid.AvailabilityZone expectedActiveCommitment string // non-empty if VM should be assigned to a commitment @@ -489,7 +182,7 @@ func TestUsageCalculator_ExpiredAndFutureCommitments(t *testing.T) { { name: "active commitment - within time range", projectID: "project-A", - vms: []VMRow{ + vms: []commitments.VMRow{ { ID: "vm-001", Name: "vm-001", Status: "ACTIVE", AZ: "az-a", @@ -513,7 +206,7 @@ func TestUsageCalculator_ExpiredAndFutureCommitments(t *testing.T) { { name: "expired commitment - should be ignored (VM goes to PAYG)", projectID: "project-A", - vms: []VMRow{ + vms: []commitments.VMRow{ { ID: "vm-001", Name: "vm-001", Status: "ACTIVE", AZ: "az-a", @@ -534,7 +227,7 @@ func TestUsageCalculator_ExpiredAndFutureCommitments(t *testing.T) { { name: "future commitment - should be ignored (VM goes to PAYG)", projectID: "project-A", - vms: []VMRow{ + vms: []commitments.VMRow{ { ID: "vm-001", Name: "vm-001", Status: "ACTIVE", AZ: "az-a", @@ -555,7 +248,7 @@ func TestUsageCalculator_ExpiredAndFutureCommitments(t *testing.T) { { name: "mixed - only active commitment is used", projectID: "project-A", - vms: []VMRow{ + vms: []commitments.VMRow{ { ID: "vm-001", Name: "vm-001", Status: "ACTIVE", AZ: "az-a", @@ -611,12 +304,12 @@ func TestUsageCalculator_ExpiredAndFutureCommitments(t *testing.T) { Build() dbClient := &mockUsageDBClient{ - rows: map[string][]VMRow{ + rows: map[string][]commitments.VMRow{ tt.projectID: tt.vms, }, } - calc := NewUsageCalculator(k8sClient, dbClient) + calc := commitments.NewUsageCalculator(k8sClient, dbClient) logger := log.FromContext(ctx) report, err := calc.CalculateUsage(ctx, logger, tt.projectID, tt.allAZs) if err != nil { @@ -679,14 +372,14 @@ func TestUsageMultipleCalculation_FloorDivision(t *testing.T) { tests := []struct { name string - vms []VMRow + vms []commitments.VMRow expectedRAM uint64 // Expected RAM usage in units expectedCores uint64 // Expected cores usage expectedInstances uint64 }{ { name: "single smallest flavor - 1 unit", - vms: []VMRow{ + vms: []commitments.VMRow{ { ID: "vm-001", Name: "vm-001", Status: "ACTIVE", AZ: "az-a", @@ -700,7 +393,7 @@ func TestUsageMultipleCalculation_FloorDivision(t *testing.T) { }, { name: "2x flavor with overhead - floor(4080/2032) = 2 units, not 3", - vms: []VMRow{ + vms: []commitments.VMRow{ { ID: "vm-001", Name: "vm-001", Status: "ACTIVE", AZ: "az-a", @@ -714,7 +407,7 @@ func TestUsageMultipleCalculation_FloorDivision(t *testing.T) { }, { name: "multiple VMs - RAM units should match cores for fixed ratio", - vms: []VMRow{ + vms: []commitments.VMRow{ { ID: "vm-001", Name: "vm-001", Status: "ACTIVE", AZ: "az-a", @@ -775,12 +468,12 @@ func TestUsageMultipleCalculation_FloorDivision(t *testing.T) { Build() dbClient := &mockUsageDBClient{ - rows: map[string][]VMRow{ + rows: map[string][]commitments.VMRow{ "project-A": tt.vms, }, } - calc := NewUsageCalculator(k8sClient, dbClient) + calc := commitments.NewUsageCalculator(k8sClient, dbClient) logger := log.FromContext(ctx) report, err := calc.CalculateUsage(ctx, logger, "project-A", []liquid.AvailabilityZone{"az-a"}) if err != nil { @@ -829,130 +522,10 @@ func TestUsageMultipleCalculation_FloorDivision(t *testing.T) { } } -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)) //nolint:gosec // slot is a small test index, no overflow risk @@ -977,7 +550,6 @@ func makeUsageTestReservationWithTimes(commitmentUUID, projectID, flavorGroup, a }, } - // StartTime and EndTime are on ReservationSpec, not CommittedResourceReservationSpec if startTime != nil { res.Spec.StartTime = &metav1.Time{Time: *startTime} } diff --git a/internal/scheduling/reservations/commitments/state.go b/internal/scheduling/reservations/commitments/state.go index 45d27db93..1208a4344 100644 --- a/internal/scheduling/reservations/commitments/state.go +++ b/internal/scheduling/reservations/commitments/state.go @@ -46,10 +46,10 @@ func ResourceNameInstances(flavorGroup string) string { return resourceNamePrefix + flavorGroup + ResourceSuffixInstances } -// getFlavorGroupNameFromResource extracts the flavor group name from a LIQUID resource name. +// GetFlavorGroupNameFromResource extracts the flavor group name from a LIQUID resource name. // Only accepts _ram resources since CommitmentState is RAM-based. // Callers handling _cores or _instances must use a different approach. -func getFlavorGroupNameFromResource(resourceName string) (string, error) { +func GetFlavorGroupNameFromResource(resourceName string) (string, error) { if !strings.HasPrefix(resourceName, resourceNamePrefix) { return "", fmt.Errorf("invalid resource name: %s (missing prefix)", resourceName) } @@ -105,7 +105,7 @@ func FromCommitment( return nil, errors.New("unexpected commitment format") } - flavorGroupName, err := getFlavorGroupNameFromResource(commitment.ResourceName) + flavorGroupName, err := GetFlavorGroupNameFromResource(commitment.ResourceName) if err != nil { return nil, err } @@ -207,41 +207,6 @@ 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/state_test.go b/internal/scheduling/reservations/commitments/state_test.go index fcdbf9f84..3aba27b71 100644 --- a/internal/scheduling/reservations/commitments/state_test.go +++ b/internal/scheduling/reservations/commitments/state_test.go @@ -237,7 +237,7 @@ func TestFromReservations_NonCommittedResourceType(t *testing.T) { func TestGetFlavorGroupNameFromResource_Valid(t *testing.T) { // Test valid resource names with underscores in flavor group - name, err := getFlavorGroupNameFromResource("hw_version_hana_medium_v2_ram") + name, err := GetFlavorGroupNameFromResource("hw_version_hana_medium_v2_ram") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -254,17 +254,17 @@ func TestGetFlavorGroupNameFromResource_Invalid(t *testing.T) { "hw_version_2101", // missing suffix } for _, input := range invalidCases { - if _, err := getFlavorGroupNameFromResource(input); err == nil { + if _, err := GetFlavorGroupNameFromResource(input); err == nil { t.Errorf("expected error for %q, got nil", input) } } } func TestResourceNameRoundTrip(t *testing.T) { - // Test that ResourceNameRAM and getFlavorGroupNameFromResource are inverses + // Test that ResourceNameRAM and GetFlavorGroupNameFromResource are inverses for _, groupName := range []string{"2101", "hana_1", "hana_medium_v2"} { resourceName := ResourceNameRAM(groupName) - recovered, err := getFlavorGroupNameFromResource(resourceName) + recovered, err := GetFlavorGroupNameFromResource(resourceName) if err != nil { t.Fatalf("round-trip failed for %q: %v", groupName, err) } diff --git a/internal/scheduling/reservations/commitments/syncer.go b/internal/scheduling/reservations/commitments/syncer.go index 91708c848..60c450b9a 100644 --- a/internal/scheduling/reservations/commitments/syncer.go +++ b/internal/scheduling/reservations/commitments/syncer.go @@ -121,7 +121,7 @@ func (s *Syncer) getCommitmentStates(ctx context.Context, log logr.Logger, flavo } // Extract flavor group name from resource name (validates format: hw_version__ram) - flavorGroupName, err := getFlavorGroupNameFromResource(commitment.ResourceName) + flavorGroupName, err := GetFlavorGroupNameFromResource(commitment.ResourceName) if err != nil { log.Info("skipping commitment with invalid resource name", "id", id, diff --git a/internal/scheduling/reservations/commitments/usage.go b/internal/scheduling/reservations/commitments/usage.go index e71e09372..d634fc2a0 100644 --- a/internal/scheduling/reservations/commitments/usage.go +++ b/internal/scheduling/reservations/commitments/usage.go @@ -10,10 +10,13 @@ import ( "fmt" "sort" "strconv" + "sync" "time" "github.com/cobaltcore-dev/cortex/api/v1alpha1" + "github.com/cobaltcore-dev/cortex/internal/knowledge/datasources/plugins/openstack/nova" "github.com/cobaltcore-dev/cortex/internal/knowledge/extractor/plugins/compute" + "github.com/cobaltcore-dev/cortex/internal/scheduling/external" "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations" "github.com/go-logr/logr" . "github.com/majewsky/gg/option" @@ -21,6 +24,62 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) +// UsageDBClient is the minimal interface for querying VM usage data from Postgres. +type UsageDBClient interface { + // ListProjectVMs returns all VMs for a project with their flavor data. + ListProjectVMs(ctx context.Context, projectID string) ([]VMRow, error) +} + +// VMRow is the result of a joined server+flavor query from Postgres. +type VMRow struct { + ID string + Name string + Status string + Created string + AZ string + Hypervisor string + FlavorName string + FlavorRAM uint64 + FlavorVCPUs uint64 + FlavorDisk uint64 + FlavorExtras string // JSON string of flavor extra_specs +} + +// 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 +} + // VMUsageInfo contains VM information needed for usage calculation. // This is a local view of the VM enriched with flavor group information. type VMUsageInfo struct { @@ -527,3 +586,78 @@ func countCommitmentStates(m map[string][]*CommitmentStateWithUsage) int { } return count } + +// dbUsageClient implements UsageDBClient using a lazy-connecting PostgresReader. +type dbUsageClient struct { + k8sClient client.Client + datasourceName string + mu sync.Mutex + reader *external.PostgresReader +} + +// NewDBUsageClient creates a UsageDBClient that lazily connects to Postgres via the named Datasource CRD. +func NewDBUsageClient(k8sClient client.Client, datasourceName string) UsageDBClient { + return &dbUsageClient{k8sClient: k8sClient, datasourceName: datasourceName} +} + +func (c *dbUsageClient) getReader(ctx context.Context) (*external.PostgresReader, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.reader != nil { + return c.reader, nil + } + reader, err := external.NewPostgresReader(ctx, c.k8sClient, c.datasourceName) + if err != nil { + return nil, fmt.Errorf("failed to connect to datasource %s: %w", c.datasourceName, err) + } + c.reader = reader + return reader, nil +} + +// vmQueryRow is the scan target for the server+flavor JOIN query. +type vmQueryRow struct { + ID string `db:"id"` + Name string `db:"name"` + Status string `db:"status"` + Created string `db:"created"` + AZ string `db:"az"` + Hypervisor string `db:"hypervisor"` + FlavorName string `db:"flavor_name"` + FlavorRAM uint64 `db:"flavor_ram"` + FlavorVCPUs uint64 `db:"flavor_vcpus"` + FlavorDisk uint64 `db:"flavor_disk"` + FlavorExtras string `db:"flavor_extras"` +} + +// ListProjectVMs returns all VMs for a project joined with their flavor data from Postgres. +func (c *dbUsageClient) ListProjectVMs(ctx context.Context, projectID string) ([]VMRow, error) { + reader, err := c.getReader(ctx) + if err != nil { + return nil, err + } + + query := ` + SELECT + s.id, s.name, s.status, s.created, + s.os_ext_az_availability_zone AS az, + s.os_ext_srv_attr_hypervisor_hostname AS hypervisor, + s.flavor_name, + COALESCE(f.ram, 0) AS flavor_ram, + COALESCE(f.vcpus, 0) AS flavor_vcpus, + COALESCE(f.disk, 0) AS flavor_disk, + COALESCE(f.extra_specs, '') AS flavor_extras + FROM ` + nova.Server{}.TableName() + ` s + LEFT JOIN ` + nova.Flavor{}.TableName() + ` f ON f.name = s.flavor_name + WHERE s.tenant_id = $1` + + var rows []vmQueryRow + if err := reader.Select(ctx, &rows, query, projectID); err != nil { + return nil, fmt.Errorf("failed to query VMs for project %s: %w", projectID, err) + } + + result := make([]VMRow, len(rows)) + for i, r := range rows { + result[i] = VMRow(r) + } + return result, nil +} diff --git a/internal/scheduling/reservations/commitments/usage_db.go b/internal/scheduling/reservations/commitments/usage_db.go deleted file mode 100644 index 51d0d2299..000000000 --- a/internal/scheduling/reservations/commitments/usage_db.go +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright SAP SE -// SPDX-License-Identifier: Apache-2.0 - -package commitments - -import ( - "context" - "fmt" - "sync" - - "github.com/cobaltcore-dev/cortex/internal/knowledge/datasources/plugins/openstack/nova" - "github.com/cobaltcore-dev/cortex/internal/scheduling/external" - "sigs.k8s.io/controller-runtime/pkg/client" -) - -// dbUsageClient implements UsageDBClient using a lazy-connecting PostgresReader. -type dbUsageClient struct { - k8sClient client.Client - datasourceName string - mu sync.Mutex - reader *external.PostgresReader -} - -// NewDBUsageClient creates a UsageDBClient that lazily connects to Postgres via the named Datasource CRD. -func NewDBUsageClient(k8sClient client.Client, datasourceName string) UsageDBClient { - return &dbUsageClient{k8sClient: k8sClient, datasourceName: datasourceName} -} - -func (c *dbUsageClient) getReader(ctx context.Context) (*external.PostgresReader, error) { - c.mu.Lock() - defer c.mu.Unlock() - if c.reader != nil { - return c.reader, nil - } - reader, err := external.NewPostgresReader(ctx, c.k8sClient, c.datasourceName) - if err != nil { - return nil, fmt.Errorf("failed to connect to datasource %s: %w", c.datasourceName, err) - } - c.reader = reader - return reader, nil -} - -// vmQueryRow is the scan target for the server+flavor JOIN query. -type vmQueryRow struct { - ID string `db:"id"` - Name string `db:"name"` - Status string `db:"status"` - Created string `db:"created"` - AZ string `db:"az"` - Hypervisor string `db:"hypervisor"` - FlavorName string `db:"flavor_name"` - FlavorRAM uint64 `db:"flavor_ram"` - FlavorVCPUs uint64 `db:"flavor_vcpus"` - FlavorDisk uint64 `db:"flavor_disk"` - FlavorExtras string `db:"flavor_extras"` -} - -// ListProjectVMs returns all VMs for a project joined with their flavor data from Postgres. -func (c *dbUsageClient) ListProjectVMs(ctx context.Context, projectID string) ([]VMRow, error) { - reader, err := c.getReader(ctx) - if err != nil { - return nil, err - } - - query := ` - SELECT - s.id, s.name, s.status, s.created, - s.os_ext_az_availability_zone AS az, - s.os_ext_srv_attr_hypervisor_hostname AS hypervisor, - s.flavor_name, - COALESCE(f.ram, 0) AS flavor_ram, - COALESCE(f.vcpus, 0) AS flavor_vcpus, - COALESCE(f.disk, 0) AS flavor_disk, - COALESCE(f.extra_specs, '') AS flavor_extras - FROM ` + nova.Server{}.TableName() + ` s - LEFT JOIN ` + nova.Flavor{}.TableName() + ` f ON f.name = s.flavor_name - WHERE s.tenant_id = $1` - - var rows []vmQueryRow - if err := reader.Select(ctx, &rows, query, projectID); err != nil { - return nil, fmt.Errorf("failed to query VMs for project %s: %w", projectID, err) - } - - result := make([]VMRow, len(rows)) - for i, r := range rows { - result[i] = VMRow(r) - } - return result, nil -} diff --git a/internal/scheduling/reservations/commitments/usage_internals_test.go b/internal/scheduling/reservations/commitments/usage_internals_test.go new file mode 100644 index 000000000..d9f6c474f --- /dev/null +++ b/internal/scheduling/reservations/commitments/usage_internals_test.go @@ -0,0 +1,426 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package commitments + +import ( + "testing" + "time" +) + +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["status"] != "ACTIVE" { + t.Errorf("status = %v, expected ACTIVE", attrs["status"]) + } + + for _, absent := range []string{"metadata", "tags", "os_type"} { + if _, present := attrs[absent]; present { + t.Errorf("%s must not appear in output (not available from Postgres cache)", absent) + } + } + + flavor, ok := attrs["flavor"].(map[string]any) + if !ok { + t.Errorf("flavor is not map[string]any: %T", attrs["flavor"]) + } else { + if flavor["name"] != "m1.large" { + t.Errorf("flavor.name = %v, expected m1.large", flavor["name"]) + } + if flavor["vcpu"] != uint64(16) { + t.Errorf("flavor.vcpu = %v, expected 16", flavor["vcpu"]) + } + if flavor["ram_mib"] != uint64(4096) { + t.Errorf("flavor.ram_mib = %v, expected 4096", flavor["ram_mib"]) + } + if flavor["disk_gib"] != uint64(100) { + t.Errorf("flavor.disk_gib = %v, expected 100", flavor["disk_gib"]) + } + } + + 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"]) + } + }) + + t.Run("with video RAM - video_ram_mib present", func(t *testing.T) { + vram := uint64(16) + vmWithVRAM := vm + vmWithVRAM.VideoRAMMiB = &vram + attrs := buildVMAttributes(vmWithVRAM, "") + + flavor, ok := attrs["flavor"].(map[string]any) + if !ok { + t.Fatalf("flavor is not map[string]any: %T", attrs["flavor"]) + } + if flavor["video_ram_mib"] != uint64(16) { + t.Errorf("flavor.video_ram_mib = %v, expected 16", flavor["video_ram_mib"]) + } + if _, present := flavor["hw_version"]; present { + t.Errorf("flavor.hw_version must not appear in output") + } + }) + + t.Run("without video RAM - video_ram_mib absent", func(t *testing.T) { + attrs := buildVMAttributes(vm, "") + + flavor, ok := attrs["flavor"].(map[string]any) + if !ok { + t.Fatalf("flavor is not map[string]any: %T", attrs["flavor"]) + } + if _, present := flavor["video_ram_mib"]; present { + t.Errorf("flavor.video_ram_mib should be absent when VideoRAMMiB is nil, got %v", flavor["video_ram_mib"]) + } + }) +} + +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_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) + } + } + }) + } +}