Skip to content
Draft
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
22 changes: 22 additions & 0 deletions docs/users/api-v2-spec-rates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<!--
SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company

SPDX-License-Identifier: Apache-2.0
-->

# Limes Rate API v2 specification

The URLs indicated in the headers of each section are relative to the endpoint URL advertised in the Keystone
catalog under the service type `limes-rates`.

Where permission requirements are indicated, they refer to the default policy. Limes operators can configure their
policy differently, so that certain requests may require other roles or token scopes.

Use the table of contents icon
<img src="https://github.com/github/docs/raw/main/contributing/images/table-of-contents.png" width="25" height="25" />
near the top left corner of this document to jump to a specific section on this page.




TODO
19 changes: 19 additions & 0 deletions docs/users/api-v2-spec-resources.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<!--
SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company

SPDX-License-Identifier: Apache-2.0
-->

# Limes Resource API v2 specification

The URLs indicated in the headers of each section are relative to the endpoint URL advertised in the Keystone
catalog under the service type `resources`.

Where permission requirements are indicated, they refer to the default policy. Limes operators can configure their
policy differently, so that certain requests may require other roles or token scopes.

Use the table of contents icon
<img src="https://github.com/github/docs/raw/main/contributing/images/table-of-contents.png" width="25" height="25" />
near the top left corner of this document to jump to a specific section on this page.

TODO
16 changes: 16 additions & 0 deletions docs/users/api-v2-specification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!--
SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company

SPDX-License-Identifier: Apache-2.0
-->

# V2 API Specification

In an attempt to streamline interaction of consumers with the Limes API, we are
working on a re-designed version 2 of the API. It still differentiates the two
types of entities, **resources** and **rates**

The Limes API v2 has therefore been split into two separately documented sub-specifications:

- [Resource API spec](./api-spec-resources.md)
- [Rate API spec](./api-spec-rates.md)
20 changes: 10 additions & 10 deletions internal/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ func setupTest(t *testing.T) test.Setup {
test.WithPersistedServiceInfo("shared", srvInfoShared),
test.WithPersistedServiceInfo("unshared", srvInfoUnshared),
test.WithInitialDiscovery,
test.WithEmptyRecordsAsNeeded,
test.WithEmptyResourceRecordsAsNeeded,
)

// shorthands
Expand Down Expand Up @@ -332,7 +332,7 @@ func Test_ScrapeErrorOperations(t *testing.T) {
test.WithPersistedServiceInfo("shared", test.DefaultLiquidServiceInfo()),
test.WithPersistedServiceInfo("unshared", test.DefaultLiquidServiceInfo()),
test.WithInitialDiscovery,
test.WithEmptyRecordsAsNeeded,
test.WithEmptyResourceRecordsAsNeeded,
)

s.MustDBExec(`UPDATE project_services SET scraped_at = $1, checked_at = $1`, time.Unix(11, 0))
Expand Down Expand Up @@ -919,7 +919,7 @@ func Test_EmptyProjectList(t *testing.T) {
}`),
test.WithPersistedServiceInfo("first", test.DefaultLiquidServiceInfo()),
test.WithInitialDiscovery,
test.WithEmptyRecordsAsNeeded,
test.WithEmptyResourceRecordsAsNeeded,
)

// This warrants its own unit test since the rendering of empty project lists
Expand All @@ -943,7 +943,7 @@ func Test_EmptyProjectList(t *testing.T) {
func Test_LargeProjectList(t *testing.T) {
// to test the behavior of the project list endpoint for large lists,
// set up a config with a large number of projects (we do it via the discovery config
// in order to leverage test.WithInitialDiscover and test.WithEmptyRecordsAsNeeded)
// in order to leverage test.WithInitialDiscover and test.WithEmptyResourceRecordsAsNeeded)
projectUUIDs := make([]liquid.ProjectUUID, 100)
projectsAsConfigured := make([]core.KeystoneProject, len(projectUUIDs))
for idx := range projectUUIDs {
Expand Down Expand Up @@ -976,10 +976,10 @@ func Test_LargeProjectList(t *testing.T) {
test.WithPersistedServiceInfo("shared", test.DefaultLiquidServiceInfo()),
test.WithPersistedServiceInfo("unshared", test.DefaultLiquidServiceInfo()),
test.WithInitialDiscovery,
test.WithEmptyRecordsAsNeeded,
test.WithEmptyResourceRecordsAsNeeded,
)

// fill various fields that `test.WithEmptyRecordsAsNeeded` initializes empty with reasonably plausible dummy values
// fill various fields that `test.WithEmptyResourceRecordsAsNeeded` initializes empty with reasonably plausible dummy values
// (all those queries take an index into the project list as $1 and the project UUID as $2)
queries := []string{
`UPDATE project_services SET scraped_at = TO_TIMESTAMP($1) AT LOCAL WHERE project_id = (SELECT id FROM projects WHERE uuid = $2)`,
Expand Down Expand Up @@ -1100,7 +1100,7 @@ func Test_PutMaxQuotaOnProject(t *testing.T) {
}`),
test.WithPersistedServiceInfo("shared", test.DefaultLiquidServiceInfo()),
test.WithInitialDiscovery,
test.WithEmptyRecordsAsNeeded,
test.WithEmptyResourceRecordsAsNeeded,
)

tr, tr0 := easypg.NewTracker(t, s.DB.Db)
Expand Down Expand Up @@ -1233,7 +1233,7 @@ func Test_PutQuotaAutogrowth(t *testing.T) {
test.WithPersistedServiceInfo("shared", test.DefaultLiquidServiceInfo()),
test.WithPersistedServiceInfo("unshared", test.DefaultLiquidServiceInfo()),
test.WithInitialDiscovery,
test.WithEmptyRecordsAsNeeded,
test.WithEmptyResourceRecordsAsNeeded,
)

tr, tr0 := easypg.NewTracker(t, s.DB.Db)
Expand Down Expand Up @@ -1409,7 +1409,7 @@ func TestResourceRenaming(t *testing.T) {
test.WithPersistedServiceInfo("shared", test.DefaultLiquidServiceInfo()),
test.WithPersistedServiceInfo("unshared", test.DefaultLiquidServiceInfo()),
test.WithInitialDiscovery,
test.WithEmptyRecordsAsNeeded,
test.WithEmptyResourceRecordsAsNeeded,
)

// helper function that makes one GET query per structural level and checks
Expand Down Expand Up @@ -1667,7 +1667,7 @@ func Test_SeparatedTopologyOperations(t *testing.T) {
test.WithConfig(testAZSeparatedConfigJSON),
test.WithPersistedServiceInfo("shared", srvInfo),
test.WithInitialDiscovery,
test.WithEmptyRecordsAsNeeded,
test.WithEmptyResourceRecordsAsNeeded,
)

s.MustDBExec(`
Expand Down
2 changes: 1 addition & 1 deletion internal/api/commitment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ func setupCommitmentTest(t *testing.T, configJSON string) test.Setup {
test.WithMockLiquidClient("fourth", srvInfoFourth),
test.WithPersistedServiceInfo("fourth", srvInfoFourth),
test.WithInitialDiscovery,
test.WithEmptyRecordsAsNeeded,
test.WithEmptyResourceRecordsAsNeeded,
)

// fill `az_resources`
Expand Down
88 changes: 79 additions & 9 deletions internal/api/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,41 @@ func NewV1API(cluster *core.Cluster, tokenValidator gopherpolicy.Validator, audi
return p
}

type v2Provider struct {
Cluster *core.Cluster
DB *gorp.DbMap
VersionData VersionData
tokenValidator gopherpolicy.Validator
auditor audittools.Auditor

// slots for test doubles
timeNow func() time.Time
}

// NewV2API creates an httpapi.API that serves the Limes v2 API.
// It also returns the VersionData for this API version which is needed for the
// version advertisement on "GET /".
func NewV2API(cluster *core.Cluster, tokenValidator gopherpolicy.Validator, auditor audittools.Auditor, timeNow func() time.Time) httpapi.API {
p := &v2Provider{Cluster: cluster, DB: cluster.DB, tokenValidator: tokenValidator, auditor: auditor, timeNow: timeNow}
p.VersionData = VersionData{
Status: "CURRENT",
ID: "v2",
Links: []VersionLinkData{
{
Relation: "self",
URL: p.Path(),
},
{
Relation: "describedby",
URL: "https://github.com/sapcc/limes/blob/master/docs/users/api-v2-specification.md",
Type: "text/html",
},
},
}

return p
}

// NewTokenValidator constructs a gopherpolicy.TokenValidator instance.
func NewTokenValidator(provider *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (gopherpolicy.Validator, error) {
identityV3, err := openstack.NewIdentityV3(provider, eo)
Expand Down Expand Up @@ -179,6 +214,23 @@ func (p *v1Provider) AddTo(r *mux.Router) {
r.Methods("GET").Path("/admin/liquid/service-usage-request").HandlerFunc(p.GetServiceUsageRequest)
}

// AddTo implements the httpapi.API interface.
func (p *v2Provider) AddTo(r *mux.Router) {
r.Methods("HEAD", "GET").Path("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
httpapi.IdentifyEndpoint(r, "/")
httpapi.SkipRequestLog(r)
respondwith.JSON(w, 300, map[string]any{"versions": []VersionData{p.VersionData}})
})

r.Methods("GET").Path("/v2/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
httpapi.IdentifyEndpoint(r, "/v2/")
httpapi.SkipRequestLog(r)
respondwith.JSON(w, 200, map[string]any{"version": p.VersionData})
})

r.Methods("GET").Path("/v2/info").HandlerFunc(p.GetInfo)
}

// RequireJSON will parse the request body into the given data structure, or
// write an error response if that fails.
func RequireJSON(w http.ResponseWriter, r *http.Request, data any) bool {
Expand All @@ -190,23 +242,41 @@ func RequireJSON(w http.ResponseWriter, r *http.Request, data any) bool {
return true
}

// Path constructs a full URL for a given URL path below the /v1/ endpoint.
func (p *v1Provider) Path(elements ...string) string {
parts := []string{
strings.TrimSuffix(p.Cluster.Config.CatalogURL, "/"),
"v1",
}
func path(catalogURL, apiVersion string, elements ...string) string {
parts := []string{strings.TrimSuffix(catalogURL, "/"), apiVersion}
parts = append(parts, elements...)
return strings.Join(parts, "/")
}

// Path constructs a full URL for a given URL path below the /v1/ endpoint.
func (p *v1Provider) Path(elements ...string) string {
return path(p.Cluster.Config.CatalogURL, "v1", elements...)
}

// Path constructs a full URL for a given URL path below the /v2/ endpoint.
func (p *v2Provider) Path(elements ...string) string {
return path(p.Cluster.Config.CatalogURL, "v2", elements...)
}

// checkToken is a local helper to service the CheckToken functions of the different providers.
func checkToken(r *http.Request, tokenValidator gopherpolicy.Validator) *gopherpolicy.Token {
t := tokenValidator.CheckToken(r)
t.Context.Request = mux.Vars(r)
return t
}

// CheckToken checks the validity of the request's X-Auth-Token in Keystone, and
// returns a Token instance for checking authorization. Any errors that occur
// during this function are deferred until Require() is called.
func (p *v1Provider) CheckToken(r *http.Request) *gopherpolicy.Token {
t := p.tokenValidator.CheckToken(r)
t.Context.Request = mux.Vars(r)
return t
return checkToken(r, p.tokenValidator)
}

// CheckToken checks the validity of the request's X-Auth-Token in Keystone, and
// returns a Token instance for checking authorization. Any errors that occur
// during this function are deferred until Require() is called.
func (p *v2Provider) CheckToken(r *http.Request) *gopherpolicy.Token {
return checkToken(r, p.tokenValidator)
}

// FindDomainFromRequest loads the db.Domain referenced by the :domain_id path
Expand Down
102 changes: 102 additions & 0 deletions internal/api/fixtures/info-cluster.json
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One of my big gripes with the v1 API test suite is having a billion fixtures/*.json files. I don't want to repeat that, esp. with all the filtering that can happen once we get into combinations of ?area=foo&service=bar&category=baz&resource=qux and all the ?with= flags that we have in mind.

As a possible idea, for each "class" of tests, we could have a baseline as an assert.JSONObject literal, and then have modifier functions can we can stack on top, like

var fullInfo = assert.JSONObject { ... }
func withoutCommitmentInfo(info assert.JSONObject) assert.JSONObject { ... }

So then we could test /v2/info as cloud-admin against fullInfo and /v2/info as domain admin without commitments enabled against withoutCommitmentInfo(fullInfo), and then have all other combinations of stuff follow from that.

We will have to see how this scales to full reports with real data. I'm also open for alternative suggestions for how to structure this. But I would like to not go down the "bunch of slightly different JSON files" route again.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How would you modify the JSONObject in an efficient way?
I would support this, but only if we don't need x loops to iterate over the deep json to delete or modify the keys we want to have.
Using the jq/yq syntax would be kind of neat (IMHO), but I understand that this is not a nice dependency to have.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We agreed that modifying the full JSON with some kind of additional language would make sense, but the question is which one. We make this an exploratory task to find the right package to modify the full json objects.

Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
{
"service_areas": {
"first": {
"services": {
"first": {
"version": 1,
"display_name": "",
"resources": {
"capacity": {
"resources": {
"capacity": {
"display_name": "",
"unit": "B",
"topology": "az-aware",
"has_capacity": true,
"has_quota": true
}
}
},
"things": {
"resources": {
"things": {
"display_name": "",
"topology": "flat",
"has_capacity": false,
"has_quota": true
}
}
}
},
"rates": {
"objects:create": {
"display_name": "",
"topology": "flat",
"has_usage": true,
"limits": {
"default_limit": 5000,
"default_window": "1s"
}
},
"objects:delete": {
"display_name": "",
"unit": "MiB",
"topology": "flat",
"has_usage": true
},
"objects:unlimited": {
"display_name": "",
"unit": "KiB",
"topology": "flat",
"has_usage": true
},
"objects:update": {
"display_name": "",
"topology": "flat",
"has_usage": true
}
}
}
}
},
"second": {
"services": {
"second": {
"version": 1,
"display_name": "",
"resources": {
"capacity": {
"resources": {
"capacity": {
"display_name": "",
"unit": "B",
"topology": "az-aware",
"has_capacity": true,
"has_quota": true,
"commitment_config": {
"durations": [
"1 hour",
"2 hours"
],
"min_confirm_by": 604800
}
}
}
},
"things": {
"resources": {
"things": {
"display_name": "",
"topology": "flat",
"has_capacity": false,
"has_quota": true
}
}
}
},
"rates": {}
}
}
}
}
}
Loading