Skip to content
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

Add api endpoint to render request payloads for querying the LIQUID api #594

Merged
merged 1 commit into from
Nov 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/users/api-spec-resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -798,3 +798,13 @@ The objects at `scrape_errors[]` may contain the following fields:
| `service_type` | string | Type name of the service where this resource scrape error was observed. |
| `checked_at` | integer | UNIX timestamp of the instant when this resource scrape error was observed in the specified project and service. |
| `message` | string | The exact error message that was observed. |

### GET /admin/liquid/service-capacity-request

Generates the request body payload for querying the LIQUID API endpoint /v1/report-capacity of a specific service.
Requires the `?service_type` query parameter.

### GET /admin/liquid/service-usage-request

Generates the request body payload for querying the LIQUID API endpoint /v1/projects/:uuid/report-usage of a specific service and project.
Requires the `?service_type` and `?project_id` query parameters.
3 changes: 3 additions & 0 deletions internal/api/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,9 @@ func (p *v1Provider) AddTo(r *mux.Router) {
r.Methods("GET").Path("/v1/commitment-conversion/{service_type}/{resource_name}").HandlerFunc(p.GetCommitmentConversions)
r.Methods("POST").Path("/v1/domains/{domain_id}/projects/{project_id}/commitments/{commitment_id}/convert").HandlerFunc(p.ConvertCommitment)
r.Methods("POST").Path("/v1/domains/{domain_id}/projects/{project_id}/commitments/{commitment_id}/update-duration").HandlerFunc(p.UpdateCommitmentDuration)

r.Methods("GET").Path("/admin/liquid/service-capacity-request").HandlerFunc(p.GetServiceCapacityRequest)
r.Methods("GET").Path("/admin/liquid/service-usage-request").HandlerFunc(p.GetServiceUsageRequest)
}

// RequireJSON will parse the request body into the given data structure, or
Expand Down
121 changes: 121 additions & 0 deletions internal/api/liquid.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/******************************************************************************
*
* Copyright 2024 SAP SE
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
******************************************************************************/

package api

import (
"database/sql"
"errors"
"net/http"

"github.com/sapcc/go-bits/httpapi"
"github.com/sapcc/go-bits/respondwith"

"github.com/sapcc/limes/internal/core"
"github.com/sapcc/limes/internal/datamodel"
"github.com/sapcc/limes/internal/db"
)

// GetServiceCapacityRequest handles GET /admin/liquid/service-capacity-request?service_type=:type.
func (p *v1Provider) GetServiceCapacityRequest(w http.ResponseWriter, r *http.Request) {
httpapi.IdentifyEndpoint(r, "/admin/liquid/service-capacity-request")
token := p.CheckToken(r)
if !token.Require(w, "cluster:show") {
return
}

serviceType := r.URL.Query().Get("service_type")
if serviceType == "" {
http.Error(w, "missing required parameter: service_type", http.StatusBadRequest)
return
}

plugin, ok := p.Cluster.CapacityPlugins[serviceType]
if !ok {
http.Error(w, "invalid service type", http.StatusBadRequest)
return
}

backchannel := datamodel.NewCapacityPluginBackchannel(p.Cluster, p.DB)
serviceCapacityRequest, err := plugin.BuildServiceCapacityRequest(backchannel, p.Cluster.Config.AvailabilityZones)
if errors.Is(err, core.ErrNotALiquid) {
http.Error(w, "capacity plugin does not support LIQUID requests", http.StatusNotImplemented)
return
}
if respondwith.ErrorText(w, err) {
return
}

respondwith.JSON(w, http.StatusOK, serviceCapacityRequest)
}

// p.GetServiceUsageRequest handles GET /admin/liquid/service-usage-request?service_type=:type&project_id=:id.
func (p *v1Provider) GetServiceUsageRequest(w http.ResponseWriter, r *http.Request) {
httpapi.IdentifyEndpoint(r, "/admin/liquid/service-usage-request")
token := p.CheckToken(r)
if !token.Require(w, "cluster:show") {
return
}

serviceType := r.URL.Query().Get("service_type")
if serviceType == "" {
http.Error(w, "missing required parameter: service_type", http.StatusBadRequest)
return
}

plugin, ok := p.Cluster.QuotaPlugins[db.ServiceType(serviceType)]
if !ok {
http.Error(w, "invalid service type", http.StatusBadRequest)
return
}

projectID := r.URL.Query().Get("project_id")
if projectID == "" {
http.Error(w, "missing required parameter: project_id", http.StatusBadRequest)
return
}

var dbProject db.Project
err := p.DB.SelectOne(&dbProject, `SELECT * FROM projects WHERE id = $1`, projectID)
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "project not found", http.StatusNotFound)
return
} else if respondwith.ErrorText(w, err) {
return
}

var dbDomain db.Domain
err = p.DB.SelectOne(&dbDomain, `SELECT * FROM domains WHERE id = $1`, dbProject.DomainID)
if respondwith.ErrorText(w, err) {
return
}

domain := core.KeystoneDomainFromDB(dbDomain)
project := core.KeystoneProjectFromDB(dbProject, domain)

serviceUsageRequest, err := plugin.BuildServiceUsageRequest(project, p.Cluster.Config.AvailabilityZones)
if errors.Is(err, core.ErrNotALiquid) {
http.Error(w, "quota plugin does not support LIQUID requests", http.StatusNotImplemented)
return
}
if respondwith.ErrorText(w, err) {
return
}

respondwith.JSON(w, http.StatusOK, serviceUsageRequest)
}
180 changes: 180 additions & 0 deletions internal/api/liquid_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/*******************************************************************************
*
* Copyright 2024 SAP SE
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You should have received a copy of the License along with this
* program. If not, you may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*******************************************************************************/

package api

import (
"net/http"
"testing"

"github.com/sapcc/go-bits/assert"

"github.com/sapcc/limes/internal/test"
)

const (
liquidQuotaTestConfigYAML = `
availability_zones: [ az-one, az-two ]
discovery:
method: --test-static
params:
domains:
- { name: germany, id: uuid-for-germany }
projects:
uuid-for-germany:
- { name: berlin, id: uuid-for-berlin, parent_id: uuid-for-germany }
services:
- service_type: unittest
type: --test-generic
`
liquidCapacityTestConfigYAML = `
availability_zones: [ az-one, az-two ]
discovery:
method: --test-static
services:
- service_type: unittest
type: --test-generic
capacitors:
- id: unittest
type: --test-static
`
)

func TestGetServiceCapacityRequest(t *testing.T) {
t.Helper()
s := test.NewSetup(t,
test.WithConfig(liquidCapacityTestConfigYAML),
test.WithAPIHandler(NewV1API),
)

// endpoint requires cluster show permissions
s.TokenValidator.Enforcer.AllowView = false
assert.HTTPRequest{
Method: "GET",
Path: "/admin/liquid/service-capacity-request?service_type=unittest",
ExpectStatus: http.StatusForbidden,
}.Check(t, s.Handler)
s.TokenValidator.Enforcer.AllowView = true

// expect error when service type is missing
assert.HTTPRequest{
Method: "GET",
Path: "/admin/liquid/service-capacity-request",
ExpectStatus: http.StatusBadRequest,
ExpectBody: assert.StringData("missing required parameter: service_type\n"),
}.Check(t, s.Handler)

// expect error for invalid service type
assert.HTTPRequest{
Method: "GET",
Path: "/admin/liquid/service-capacity-request?service_type=invalid_service_type",
ExpectStatus: http.StatusBadRequest,
ExpectBody: assert.StringData("invalid service type\n"),
}.Check(t, s.Handler)

// happy path
assert.HTTPRequest{
Method: "GET",
Path: "/admin/liquid/service-capacity-request?service_type=unittest",
ExpectStatus: 200,
ExpectBody: assert.JSONObject{
"allAZs": []string{"az-one", "az-two"},
"demandByResource": assert.JSONObject{
"capacity": assert.JSONObject{
"overcommitFactor": 1.5,
"perAZ": assert.JSONObject{
"any": assert.JSONObject{
"usage": 10,
"unusedCommitments": 0,
"pendingCommitments": 0,
},
},
},
},
},
}.Check(t, s.Handler)
}

func TestServiceUsageRequest(t *testing.T) {
t.Helper()
s := test.NewSetup(t,
test.WithConfig(liquidQuotaTestConfigYAML),
test.WithAPIHandler(NewV1API),
test.WithDBFixtureFile("fixtures/start-data.sql"),
)

// endpoint requires cluster show permissions
s.TokenValidator.Enforcer.AllowView = false
assert.HTTPRequest{
Method: "GET",
Path: "/admin/liquid/service-usage-request?service_type=unittest&project_id=1",
ExpectStatus: http.StatusForbidden,
}.Check(t, s.Handler)
s.TokenValidator.Enforcer.AllowView = true

// expect error when service type is missing
assert.HTTPRequest{
Method: "GET",
Path: "/admin/liquid/service-usage-request?project_id=1",
ExpectStatus: http.StatusBadRequest,
ExpectBody: assert.StringData("missing required parameter: service_type\n"),
}.Check(t, s.Handler)

// expect error when project_id is missing
assert.HTTPRequest{
Method: "GET",
Path: "/admin/liquid/service-usage-request?service_type=unittest",
ExpectStatus: http.StatusBadRequest,
ExpectBody: assert.StringData("missing required parameter: project_id\n"),
}.Check(t, s.Handler)

// expect error for invalid service type
assert.HTTPRequest{
Method: "GET",
Path: "/admin/liquid/service-usage-request?service_type=invalid_service_type&project_id=1",
ExpectStatus: http.StatusBadRequest,
ExpectBody: assert.StringData("invalid service type\n"),
}.Check(t, s.Handler)

// expect error for invalid project_id
assert.HTTPRequest{
Method: "GET",
Path: "/admin/liquid/service-usage-request?service_type=unittest&project_id=-1",
ExpectStatus: http.StatusNotFound,
ExpectBody: assert.StringData("project not found\n"),
}.Check(t, s.Handler)

// happy path
assert.HTTPRequest{
Method: "GET",
Path: "/admin/liquid/service-usage-request?service_type=unittest&project_id=1",
ExpectStatus: 200,
ExpectBody: assert.JSONObject{
"allAZs": []string{"az-one", "az-two"},
"projectMetadata": assert.JSONObject{
"uuid": "uuid-for-berlin",
"name": "berlin",
"domain": assert.JSONObject{
"uuid": "uuid-for-germany",
"name": "germany",
},
},
},
}.Check(t, s.Handler)
}
13 changes: 13 additions & 0 deletions internal/core/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ package core

import (
"context"
"errors"
"math/big"

"github.com/gophercloud/gophercloud/v2"
Expand Down Expand Up @@ -132,6 +133,11 @@ type QuotaPlugin interface {
// The `serializedMetrics` return value is persisted in the Limes DB and
// supplied to all subsequent RenderMetrics calls.
Scrape(ctx context.Context, project KeystoneProject, allAZs []limes.AvailabilityZone) (result map[liquid.ResourceName]ResourceData, serializedMetrics []byte, err error)

// BuildServiceUsageRequest generates the request body payload for querying
// the LIQUID API endpoint /v1/projects/:uuid/report-usage
BuildServiceUsageRequest(project KeystoneProject, allAZs []limes.AvailabilityZone) (liquid.ServiceUsageRequest, error)

// SetQuota updates the backend service's quotas for the given project in the
// given domain to the values specified here. The map is guaranteed to contain
// values for all resources defined by Resources().
Expand Down Expand Up @@ -233,6 +239,10 @@ type CapacityPlugin interface {
// supplied to all subsequent RenderMetrics calls.
Scrape(ctx context.Context, backchannel CapacityPluginBackchannel, allAZs []limes.AvailabilityZone) (result map[db.ServiceType]map[liquid.ResourceName]PerAZ[CapacityData], serializedMetrics []byte, err error)

// BuildServiceCapacityRequest generates the request body payload for querying
// the LIQUID API endpoint /v1/report-capacity
BuildServiceCapacityRequest(backchannel CapacityPluginBackchannel, allAZs []limes.AvailabilityZone) (liquid.ServiceCapacityRequest, error)

// DescribeMetrics is called when Prometheus is scraping metrics from
// limes-collect, to provide an opportunity to the plugin to emit its own
// metrics.
Expand Down Expand Up @@ -271,3 +281,6 @@ var (
// CapacityPluginRegistry is a pluggable.Registry for CapacityPlugin implementations.
CapacityPluginRegistry pluggable.Registry[CapacityPlugin]
)

// ErrNotALiquid is a custom eror that is thrown by plugins that do not support the LIQUID API
var ErrNotALiquid = errors.New("this plugin is not a liquid")
Loading