diff --git a/internal/scheduling/reservations/commitments/api.go b/internal/scheduling/reservations/commitments/api.go index b35bece97..06fb97be1 100644 --- a/internal/scheduling/reservations/commitments/api.go +++ b/internal/scheduling/reservations/commitments/api.go @@ -6,6 +6,7 @@ package commitments import ( "context" "net/http" + "strings" "sync" "github.com/cobaltcore-dev/cortex/internal/scheduling/nova" @@ -57,10 +58,23 @@ func (api *HTTPAPI) Init(mux *http.ServeMux, registry prometheus.Registerer, log mux.HandleFunc("/commitments/v1/change-commitments", api.HandleChangeCommitments) mux.HandleFunc("/commitments/v1/report-capacity", api.HandleReportCapacity) mux.HandleFunc("/commitments/v1/info", api.HandleInfo) - mux.HandleFunc("/commitments/v1/projects/", api.HandleReportUsage) // matches /commitments/v1/projects/:project_id/report-usage + mux.HandleFunc("/commitments/v1/projects/", api.handleProjectEndpoint) // routes to report-usage or quota log.Info("commitments API initialized", "changeCommitmentsEnabled", api.config.EnableChangeCommitmentsAPI, "reportUsageEnabled", api.config.EnableReportUsageAPI, "reportCapacityEnabled", api.config.EnableReportCapacityAPI) } + +// handleProjectEndpoint routes /commitments/v1/projects/:project_id/... requests to the appropriate handler. +func (api *HTTPAPI) handleProjectEndpoint(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + switch { + case strings.HasSuffix(path, "/report-usage"): + api.HandleReportUsage(w, r) + case strings.HasSuffix(path, "/quota"): + api.HandleQuota(w, r) + default: + http.Error(w, "Not found", http.StatusNotFound) + } +} diff --git a/internal/scheduling/reservations/commitments/api_change_commitments.go b/internal/scheduling/reservations/commitments/api_change_commitments.go index 3281fc6ec..5c2436267 100644 --- a/internal/scheduling/reservations/commitments/api_change_commitments.go +++ b/internal/scheduling/reservations/commitments/api_change_commitments.go @@ -36,7 +36,7 @@ func sortedKeys[K ~string, V any](m map[K]V) []K { return keys } -// implements POST /v1/change-commitments from Limes LIQUID API: +// implements POST /commitments/v1/change-commitments from Limes LIQUID API: // 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 // @@ -69,7 +69,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", "/v1/change-commitments") + logger := LoggerFromContext(ctx).WithValues("component", "api", "endpoint", "/commitments/v1/change-commitments") // Only accept POST method if r.Method != http.MethodPost { diff --git a/internal/scheduling/reservations/commitments/api_info.go b/internal/scheduling/reservations/commitments/api_info.go index d25235d99..9b2b8eb4a 100644 --- a/internal/scheduling/reservations/commitments/api_info.go +++ b/internal/scheduling/reservations/commitments/api_info.go @@ -22,7 +22,7 @@ import ( // errInternalServiceInfo indicates an internal error while building service info (e.g., invalid unit configuration) var errInternalServiceInfo = errors.New("internal error building service info") -// handles GET /v1/info requests from Limes: +// handles GET /commitments/v1/info 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 func (api *HTTPAPI) HandleInfo(w http.ResponseWriter, r *http.Request) { @@ -38,7 +38,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", "/v1/info") + logger := LoggerFromContext(ctx).WithValues("component", "api", "endpoint", "/commitments/v1/info") // Only accept GET method if r.Method != http.MethodGet { @@ -152,7 +152,7 @@ func (api *HTTPAPI) buildServiceInfo(ctx context.Context, logger logr.Logger) (l Unit: unit, // Non-standard unit: multiples of smallest flavor RAM Topology: liquid.AZAwareTopology, // Commitments are per-AZ NeedsResourceDemand: false, // Capacity planning out of scope for now - HasCapacity: handlesCommitments, // We report capacity via /v1/report-capacity only for groups that accept commitments + HasCapacity: handlesCommitments, // We report capacity via /commitments/v1/report-capacity only for groups that accept commitments HasQuota: false, // No quota enforcement as of now HandlesCommitments: handlesCommitments, // Only for groups with fixed RAM/core ratio Attributes: attrsJSON, diff --git a/internal/scheduling/reservations/commitments/api_info_test.go b/internal/scheduling/reservations/commitments/api_info_test.go index 846d4c279..256709957 100644 --- a/internal/scheduling/reservations/commitments/api_info_test.go +++ b/internal/scheduling/reservations/commitments/api_info_test.go @@ -30,7 +30,7 @@ func TestHandleInfo_KnowledgeNotReady(t *testing.T) { api := NewAPI(k8sClient) - req := httptest.NewRequest(http.MethodGet, "/v1/info", http.NoBody) + req := httptest.NewRequest(http.MethodGet, "/commitments/v1/info", http.NoBody) w := httptest.NewRecorder() api.HandleInfo(w, req) @@ -63,7 +63,7 @@ func TestHandleInfo_MethodNotAllowed(t *testing.T) { api := NewAPI(k8sClient) // Use POST instead of GET - req := httptest.NewRequest(http.MethodPost, "/v1/info", http.NoBody) + req := httptest.NewRequest(http.MethodPost, "/commitments/v1/info", http.NoBody) w := httptest.NewRecorder() api.HandleInfo(w, req) @@ -124,7 +124,7 @@ func TestHandleInfo_InvalidFlavorMemory(t *testing.T) { api := NewAPI(k8sClient) - req := httptest.NewRequest(http.MethodGet, "/v1/info", http.NoBody) + req := httptest.NewRequest(http.MethodGet, "/commitments/v1/info", http.NoBody) w := httptest.NewRecorder() api.HandleInfo(w, req) @@ -197,7 +197,7 @@ func TestHandleInfo_HasCapacityEqualsHandlesCommitments(t *testing.T) { api := NewAPI(k8sClient) - req := httptest.NewRequest(http.MethodGet, "/v1/info", http.NoBody) + req := httptest.NewRequest(http.MethodGet, "/commitments/v1/info", http.NoBody) w := httptest.NewRecorder() api.HandleInfo(w, req) diff --git a/internal/scheduling/reservations/commitments/api_quota.go b/internal/scheduling/reservations/commitments/api_quota.go new file mode 100644 index 000000000..184728f39 --- /dev/null +++ b/internal/scheduling/reservations/commitments/api_quota.go @@ -0,0 +1,40 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package commitments + +import ( + "net/http" + + "github.com/google/uuid" +) + +// HandleQuota implements PUT /commitments/v1/projects/:project_id/quota from Limes LIQUID API. +// See: https://pkg.go.dev/github.com/sapcc/go-api-declarations/liquid +// +// This is a no-op endpoint that accepts quota requests but doesn't store them. +// Cortex does not enforce quotas for committed resources - quota enforcement +// happens through commitment validation at change-commitments time. +// The endpoint exists for API compatibility with the LIQUID specification. +func (api *HTTPAPI) HandleQuota(w http.ResponseWriter, r *http.Request) { + // Extract or generate request ID for tracing + requestID := r.Header.Get("X-Request-ID") + if requestID == "" { + requestID = uuid.New().String() + } + w.Header().Set("X-Request-ID", requestID) + + log := baseLog.WithValues("requestID", requestID, "endpoint", "quota") + + if r.Method != http.MethodPut { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // No-op: Accept the quota request but don't store it + // Cortex handles capacity through commitments, not quotas + log.V(1).Info("received quota request (no-op)", "path", r.URL.Path) + + // Return 204 No Content as expected by the LIQUID API + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/scheduling/reservations/commitments/api_report_capacity_test.go b/internal/scheduling/reservations/commitments/api_report_capacity_test.go index 455127a8d..4dd642c0e 100644 --- a/internal/scheduling/reservations/commitments/api_report_capacity_test.go +++ b/internal/scheduling/reservations/commitments/api_report_capacity_test.go @@ -93,9 +93,9 @@ func TestHandleReportCapacity(t *testing.T) { if err != nil { t.Fatal(err) } - req = httptest.NewRequest(tt.method, "/v1/report-capacity", bytes.NewReader(bodyBytes)) + req = httptest.NewRequest(tt.method, "/commitments/v1/report-capacity", bytes.NewReader(bodyBytes)) } else { - req = httptest.NewRequest(tt.method, "/v1/report-capacity", http.NoBody) + req = httptest.NewRequest(tt.method, "/commitments/v1/report-capacity", http.NoBody) } req = req.WithContext(context.Background()) diff --git a/internal/scheduling/reservations/commitments/api_report_usage.go b/internal/scheduling/reservations/commitments/api_report_usage.go index 53f210599..f54917e1a 100644 --- a/internal/scheduling/reservations/commitments/api_report_usage.go +++ b/internal/scheduling/reservations/commitments/api_report_usage.go @@ -99,15 +99,16 @@ func (api *HTTPAPI) recordUsageMetrics(statusCode int, startTime time.Time) { } // extractProjectIDFromPath extracts the project UUID from the URL path. -// Expected path format: /commitments/v1/projects/:project_id/report-usage +// Expected path format: /commitments/v1/projects/:project_id/ +// where is "report-usage" or "quota" func extractProjectIDFromPath(path string) (string, error) { - // Path: /commitments/v1/projects//report-usage + // Path: /commitments/v1/projects// parts := strings.Split(strings.Trim(path, "/"), "/") - // Expected: ["v1", "commitments", "projects", "", "report-usage"] + // Expected: ["commitments", "v1", "projects", "", ""] if len(parts) < 5 { return "", fmt.Errorf("path too short: %s", path) } - if parts[2] != "projects" || parts[4] != "report-usage" { + if parts[0] != "commitments" || parts[1] != "v1" || parts[2] != "projects" { return "", fmt.Errorf("unexpected path format: %s", path) } projectID := parts[3]