-
Notifications
You must be signed in to change notification settings - Fork 6
Adding committed resource usage API #614
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
1f4a445
9f45167
29ce3ed
d5e79e2
e6df6fd
63a1e18
613305e
50bb782
e549095
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,36 +4,47 @@ | |
| package commitments | ||
|
|
||
| import ( | ||
| "context" | ||
| "net/http" | ||
| "sync" | ||
|
|
||
| "github.com/cobaltcore-dev/cortex/internal/scheduling/nova" | ||
| ctrl "sigs.k8s.io/controller-runtime" | ||
| "sigs.k8s.io/controller-runtime/pkg/client" | ||
| ) | ||
|
|
||
| // UsageNovaClient is a minimal interface for the Nova client needed by the usage API. | ||
| // This allows for easy mocking in tests without implementing the full NovaClient interface. | ||
| type UsageNovaClient interface { | ||
| ListProjectServers(ctx context.Context, projectID string) ([]nova.ServerDetail, error) | ||
| } | ||
|
|
||
| // HTTPAPI implements Limes LIQUID commitment validation endpoints. | ||
| type HTTPAPI struct { | ||
| client client.Client | ||
| config Config | ||
| client client.Client | ||
| config Config | ||
| novaClient UsageNovaClient | ||
| // Mutex to serialize change-commitments requests | ||
| changeMutex sync.Mutex | ||
| } | ||
|
|
||
| func NewAPI(client client.Client) *HTTPAPI { | ||
| return NewAPIWithConfig(client, DefaultConfig()) | ||
| return NewAPIWithConfig(client, DefaultConfig(), nil) | ||
| } | ||
|
Comment on lines
34
to
36
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don’t publish Line 35 still passes Minimal guard func (api *HTTPAPI) Init(mux *http.ServeMux, registry prometheus.Registerer) {
registry.MustRegister(&api.monitor)
registry.MustRegister(&api.usageMonitor)
registry.MustRegister(&api.capacityMonitor)
mux.HandleFunc("/v1/commitments/change-commitments", api.HandleChangeCommitments)
mux.HandleFunc("/v1/commitments/report-capacity", api.HandleReportCapacity)
mux.HandleFunc("/v1/commitments/info", api.HandleInfo)
- mux.HandleFunc("/v1/commitments/projects/", api.HandleReportUsage) // matches /v1/commitments/projects/:project_id/report-usage
+ if api.novaClient != nil {
+ mux.HandleFunc("/v1/commitments/projects/", api.HandleReportUsage) // matches /v1/commitments/projects/:project_id/report-usage
+ }
}A stronger fix would be to make Also applies to: 49-56 🤖 Prompt for AI Agents |
||
|
|
||
| func NewAPIWithConfig(client client.Client, config Config) *HTTPAPI { | ||
| func NewAPIWithConfig(client client.Client, config Config, novaClient UsageNovaClient) *HTTPAPI { | ||
| return &HTTPAPI{ | ||
| client: client, | ||
| config: config, | ||
| client: client, | ||
| config: config, | ||
| novaClient: novaClient, | ||
| } | ||
| } | ||
|
|
||
| func (api *HTTPAPI) Init(mux *http.ServeMux) { | ||
| mux.HandleFunc("/v1/commitments/change-commitments", api.HandleChangeCommitments) | ||
| // mux.HandleFunc("/v1/report-capacity", api.HandleReportCapacity) | ||
| mux.HandleFunc("/v1/commitments/info", api.HandleInfo) | ||
| mux.HandleFunc("/v1/commitments/projects/", api.HandleReportUsage) // matches /v1/commitments/projects/:project_id/report-usage | ||
| } | ||
|
|
||
| var commitmentApiLog = ctrl.Log.WithName("commitment_api") | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,84 @@ | ||||||||||||||||||||||||||||||||
| // Copyright SAP SE | ||||||||||||||||||||||||||||||||
| // SPDX-License-Identifier: Apache-2.0 | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| package commitments | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| import ( | ||||||||||||||||||||||||||||||||
| "encoding/json" | ||||||||||||||||||||||||||||||||
| "fmt" | ||||||||||||||||||||||||||||||||
| "net/http" | ||||||||||||||||||||||||||||||||
| "strings" | ||||||||||||||||||||||||||||||||
| "time" | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| "github.com/sapcc/go-api-declarations/liquid" | ||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // HandleReportUsage implements POST /v1/commitments/projects/:project_id/report-usage from Limes LIQUID API. | ||||||||||||||||||||||||||||||||
| // See: https://github.com/sapcc/go-api-declarations/blob/main/liquid/report_usage.go | ||||||||||||||||||||||||||||||||
| // See: https://pkg.go.dev/github.com/sapcc/go-api-declarations/liquid | ||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||
| // This endpoint reports usage information for a specific project's committed resources, | ||||||||||||||||||||||||||||||||
| // including per-AZ usage, physical usage, and detailed VM subresources. | ||||||||||||||||||||||||||||||||
| func (api *HTTPAPI) HandleReportUsage(w http.ResponseWriter, r *http.Request) { | ||||||||||||||||||||||||||||||||
| requestID := r.Header.Get("X-Request-ID") | ||||||||||||||||||||||||||||||||
| if requestID == "" { | ||||||||||||||||||||||||||||||||
| requestID = fmt.Sprintf("req-%d", time.Now().UnixNano()) | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| log := commitmentApiLog.WithValues("requestID", requestID, "endpoint", "report-usage") | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| if r.Method != http.MethodPost { | ||||||||||||||||||||||||||||||||
| http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | ||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // Extract project UUID from URL path | ||||||||||||||||||||||||||||||||
| // URL pattern: /v1/commitments/projects/:project_id/report-usage | ||||||||||||||||||||||||||||||||
| projectID, err := extractProjectIDFromPath(r.URL.Path) | ||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||
| log.Error(err, "failed to extract project ID from path") | ||||||||||||||||||||||||||||||||
| http.Error(w, "Invalid URL path: "+err.Error(), http.StatusBadRequest) | ||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // Parse request body | ||||||||||||||||||||||||||||||||
| var req liquid.ServiceUsageRequest | ||||||||||||||||||||||||||||||||
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | ||||||||||||||||||||||||||||||||
| log.Error(err, "failed to decode request body") | ||||||||||||||||||||||||||||||||
| http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest) | ||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // Use UsageCalculator to build usage report | ||||||||||||||||||||||||||||||||
| calculator := NewUsageCalculator(api.client, api.novaClient) | ||||||||||||||||||||||||||||||||
| report, err := calculator.CalculateUsage(r.Context(), log, projectID, req.AllAZs) | ||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||
| log.Error(err, "failed to calculate usage report", "projectID", projectID) | ||||||||||||||||||||||||||||||||
| http.Error(w, "Failed to generate usage report: "+err.Error(), http.StatusInternalServerError) | ||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| w.Header().Set("Content-Type", "application/json") | ||||||||||||||||||||||||||||||||
| w.WriteHeader(http.StatusOK) | ||||||||||||||||||||||||||||||||
| if err := json.NewEncoder(w).Encode(report); err != nil { | ||||||||||||||||||||||||||||||||
| log.Error(err, "failed to encode usage report") | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // extractProjectIDFromPath extracts the project UUID from the URL path. | ||||||||||||||||||||||||||||||||
| // Expected path format: /v1/commitments/projects/:project_id/report-usage | ||||||||||||||||||||||||||||||||
| func extractProjectIDFromPath(path string) (string, error) { | ||||||||||||||||||||||||||||||||
| // Path: /v1/commitments/projects/<uuid>/report-usage | ||||||||||||||||||||||||||||||||
| parts := strings.Split(strings.Trim(path, "/"), "/") | ||||||||||||||||||||||||||||||||
| // Expected: ["v1", "commitments", "projects", "<uuid>", "report-usage"] | ||||||||||||||||||||||||||||||||
| if len(parts) < 5 { | ||||||||||||||||||||||||||||||||
| return "", fmt.Errorf("path too short: %s", path) | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| if parts[2] != "projects" || parts[4] != "report-usage" { | ||||||||||||||||||||||||||||||||
| return "", fmt.Errorf("unexpected path format: %s", path) | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| projectID := parts[3] | ||||||||||||||||||||||||||||||||
|
Comment on lines
+92
to
+100
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reject trailing path segments in the route parser.
Proposed fix- if len(parts) < 5 {
- return "", fmt.Errorf("path too short: %s", path)
- }
- if parts[2] != "projects" || parts[4] != "report-usage" {
+ if len(parts) != 5 || parts[0] != "v1" || parts[1] != "commitments" || parts[2] != "projects" || parts[4] != "report-usage" {
return "", fmt.Errorf("unexpected path format: %s", path)
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||
| if projectID == "" { | ||||||||||||||||||||||||||||||||
| return "", fmt.Errorf("empty project ID in path: %s", path) | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| return projectID, nil | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Resource leak:
deferinside loop accumulates closers.Using
defer resp.Body.Close()inside a loop means all response bodies stay open until the function returns. For paginated responses with many pages, this can exhaust file descriptors.🐛 Proposed fix: close body immediately after use
resp, err := api.sc.HTTPClient.Do(req) if err != nil { return nil, err } - defer resp.Body.Close() if resp.StatusCode != http.StatusOK { + resp.Body.Close() return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } // ... decode logic ... if err := json.NewDecoder(resp.Body).Decode(&list); err != nil { + resp.Body.Close() return nil, err } + resp.Body.Close() // Convert to ServerDetailAlternatively, extract the HTTP request logic into a helper function where
deferwould be scoped correctly.🤖 Prompt for AI Agents