• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

sapcc / limes / 13265001433

11 Feb 2025 02:17PM UTC coverage: 79.745% (+0.1%) from 79.629%
13265001433

Pull #661

github

Varsius
add api endpoint for commitment merging
Pull Request #661: add api endpoint for commitment merging

110 of 126 new or added lines in 3 files covered. (87.3%)

126 existing lines in 5 files now uncovered.

5681 of 7124 relevant lines covered (79.74%)

62.21 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

79.77
/internal/api/commitment.go
1
/******************************************************************************
2
*
3
*  Copyright 2023 SAP SE
4
*
5
*  Licensed under the Apache License, Version 2.0 (the "License");
6
*  you may not use this file except in compliance with the License.
7
*  You may obtain a copy of the License at
8
*
9
*      http://www.apache.org/licenses/LICENSE-2.0
10
*
11
*  Unless required by applicable law or agreed to in writing, software
12
*  distributed under the License is distributed on an "AS IS" BASIS,
13
*  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
*  See the License for the specific language governing permissions and
15
*  limitations under the License.
16
*
17
******************************************************************************/
18

19
package api
20

21
import (
22
        "database/sql"
23
        "encoding/json"
24
        "errors"
25
        "fmt"
26
        "net/http"
27
        "slices"
28
        "strings"
29
        "time"
30

31
        "github.com/gorilla/mux"
32
        "github.com/sapcc/go-api-declarations/cadf"
33
        "github.com/sapcc/go-api-declarations/limes"
34
        limesresources "github.com/sapcc/go-api-declarations/limes/resources"
35
        "github.com/sapcc/go-api-declarations/liquid"
36
        "github.com/sapcc/go-bits/audittools"
37
        "github.com/sapcc/go-bits/gopherpolicy"
38
        "github.com/sapcc/go-bits/httpapi"
39
        "github.com/sapcc/go-bits/must"
40
        "github.com/sapcc/go-bits/respondwith"
41
        "github.com/sapcc/go-bits/sqlext"
42

43
        "github.com/sapcc/limes/internal/core"
44
        "github.com/sapcc/limes/internal/datamodel"
45
        "github.com/sapcc/limes/internal/db"
46
        "github.com/sapcc/limes/internal/liquids"
47
        "github.com/sapcc/limes/internal/reports"
48
)
49

50
var (
51
        getProjectCommitmentsQuery = sqlext.SimplifyWhitespace(`
52
                SELECT pc.*
53
                  FROM project_commitments pc
54
                  JOIN project_az_resources par ON pc.az_resource_id = par.id
55
                  JOIN project_resources pr ON par.resource_id = pr.id {{AND pr.name = $resource_name}}
56
                  JOIN project_services ps ON pr.service_id = ps.id {{AND ps.type = $service_type}}
57
                 WHERE %s AND pc.state NOT IN ('superseded', 'expired')
58
                 ORDER BY pc.id
59
        `)
60

61
        getProjectAZResourceLocationsQuery = sqlext.SimplifyWhitespace(`
62
                SELECT par.id, ps.type, pr.name, par.az
63
                  FROM project_az_resources par
64
                  JOIN project_resources pr ON par.resource_id = pr.id {{AND pr.name = $resource_name}}
65
                  JOIN project_services ps ON pr.service_id = ps.id {{AND ps.type = $service_type}}
66
                 WHERE %s
67
        `)
68

69
        findProjectCommitmentByIDQuery = sqlext.SimplifyWhitespace(`
70
                SELECT pc.*
71
                  FROM project_commitments pc
72
                  JOIN project_az_resources par ON pc.az_resource_id = par.id
73
                  JOIN project_resources pr ON par.resource_id = pr.id
74
                  JOIN project_services ps ON pr.service_id = ps.id
75
                 WHERE pc.id = $1 AND ps.project_id = $2
76
        `)
77

78
        // NOTE: The third output column is `resourceAllowsCommitments`.
79
        // We should be checking for `ResourceUsageReport.Forbidden == true`, but
80
        // since the `Forbidden` field is not persisted in the DB, we need to use
81
        // `max_quota_from_backend` as a proxy.
82
        findProjectAZResourceIDByLocationQuery = sqlext.SimplifyWhitespace(`
83
                SELECT pr.id, par.id, pr.max_quota_from_backend IS NULL
84
                  FROM project_az_resources par
85
                  JOIN project_resources pr ON par.resource_id = pr.id
86
                  JOIN project_services ps ON pr.service_id = ps.id
87
                 WHERE ps.project_id = $1 AND ps.type = $2 AND pr.name = $3 AND par.az = $4
88
        `)
89

90
        findProjectAZResourceLocationByIDQuery = sqlext.SimplifyWhitespace(`
91
                SELECT ps.type, pr.name, par.az
92
                  FROM project_az_resources par
93
                  JOIN project_resources pr ON par.resource_id = pr.id
94
                  JOIN project_services ps ON pr.service_id = ps.id
95
                 WHERE par.id = $1
96
        `)
97
        getCommitmentWithMatchingTransferTokenQuery = sqlext.SimplifyWhitespace(`
98
                SELECT * FROM project_commitments WHERE id = $1 AND transfer_token = $2
99
        `)
100
        findCommitmentByTransferToken = sqlext.SimplifyWhitespace(`
101
                SELECT * FROM project_commitments WHERE transfer_token = $1
102
        `)
103
        findTargetAZResourceIDBySourceIDQuery = sqlext.SimplifyWhitespace(`
104
                WITH source as (
105
                SELECT pr.id AS resource_id, ps.type, pr.name, par.az
106
                  FROM project_az_resources as par
107
                  JOIN project_resources pr ON par.resource_id = pr.id
108
                  JOIN project_services ps ON pr.service_id = ps.id
109
                 WHERE par.id = $1
110
                )
111
                SELECT s.resource_id, pr.id, par.id
112
                  FROM project_az_resources as par
113
                  JOIN project_resources pr ON par.resource_id = pr.id
114
                  JOIN project_services ps ON pr.service_id = ps.id
115
                  JOIN source s ON ps.type = s.type AND pr.name = s.name AND par.az = s.az
116
                 WHERE ps.project_id = $2
117
        `)
118
        findTargetAZResourceByTargetProjectQuery = sqlext.SimplifyWhitespace(`
119
                SELECT pr.id, par.id
120
                  FROM project_az_resources par
121
                  JOIN project_resources pr ON par.resource_id = pr.id
122
                  JOIN project_services ps ON pr.service_id = ps.id
123
                 WHERE ps.project_id = $1 AND ps.type = $2 AND pr.name = $3 AND par.az = $4
124
        `)
125
        forceImmediateCapacityScrapeQuery = sqlext.SimplifyWhitespace(`
126
                UPDATE cluster_capacitors SET next_scrape_at = $1 WHERE capacitor_id = (
127
                        SELECT capacitor_id FROM cluster_services cs JOIN cluster_resources cr ON cs.id = cr.service_id
128
                        WHERE cs.type = $2 AND cr.name = $3
129
                )
130
        `)
131
)
132

133
// GetProjectCommitments handles GET /v1/domains/:domain_id/projects/:project_id/commitments.
134
func (p *v1Provider) GetProjectCommitments(w http.ResponseWriter, r *http.Request) {
15✔
135
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments")
15✔
136
        token := p.CheckToken(r)
15✔
137
        if !token.Require(w, "project:show") {
16✔
138
                return
1✔
139
        }
1✔
140
        dbDomain := p.FindDomainFromRequest(w, r)
14✔
141
        if dbDomain == nil {
15✔
142
                return
1✔
143
        }
1✔
144
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
13✔
145
        if dbProject == nil {
14✔
146
                return
1✔
147
        }
1✔
148

149
        // enumerate project AZ resources
150
        filter := reports.ReadFilter(r, p.Cluster)
12✔
151
        queryStr, joinArgs := filter.PrepareQuery(getProjectAZResourceLocationsQuery)
12✔
152
        whereStr, whereArgs := db.BuildSimpleWhereClause(map[string]any{"ps.project_id": dbProject.ID}, len(joinArgs))
12✔
153
        azResourceLocationsByID := make(map[db.ProjectAZResourceID]datamodel.AZResourceLocation)
12✔
154
        err := sqlext.ForeachRow(p.DB, fmt.Sprintf(queryStr, whereStr), append(joinArgs, whereArgs...), func(rows *sql.Rows) error {
137✔
155
                var (
125✔
156
                        id  db.ProjectAZResourceID
125✔
157
                        loc datamodel.AZResourceLocation
125✔
158
                )
125✔
159
                err := rows.Scan(&id, &loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
125✔
160
                if err != nil {
125✔
161
                        return err
×
162
                }
×
163
                // this check is defense in depth (the DB should be consistent with our config)
164
                if p.Cluster.HasResource(loc.ServiceType, loc.ResourceName) {
250✔
165
                        azResourceLocationsByID[id] = loc
125✔
166
                }
125✔
167
                return nil
125✔
168
        })
169
        if respondwith.ErrorText(w, err) {
12✔
170
                return
×
171
        }
×
172

173
        // enumerate relevant project commitments
174
        queryStr, joinArgs = filter.PrepareQuery(getProjectCommitmentsQuery)
12✔
175
        whereStr, whereArgs = db.BuildSimpleWhereClause(map[string]any{"ps.project_id": dbProject.ID}, len(joinArgs))
12✔
176
        var dbCommitments []db.ProjectCommitment
12✔
177
        _, err = p.DB.Select(&dbCommitments, fmt.Sprintf(queryStr, whereStr), append(joinArgs, whereArgs...)...)
12✔
178
        if respondwith.ErrorText(w, err) {
12✔
179
                return
×
180
        }
×
181

182
        // render response
183
        result := make([]limesresources.Commitment, 0, len(dbCommitments))
12✔
184
        for _, c := range dbCommitments {
26✔
185
                loc, exists := azResourceLocationsByID[c.AZResourceID]
14✔
186
                if !exists {
14✔
187
                        // defense in depth (the DB should not change that much between those two queries above)
×
188
                        continue
×
189
                }
190
                result = append(result, p.convertCommitmentToDisplayForm(c, loc, token))
14✔
191
        }
192

193
        respondwith.JSON(w, http.StatusOK, map[string]any{"commitments": result})
12✔
194
}
195

196
func (p *v1Provider) convertCommitmentToDisplayForm(c db.ProjectCommitment, loc datamodel.AZResourceLocation, token *gopherpolicy.Token) limesresources.Commitment {
79✔
197
        resInfo := p.Cluster.InfoForResource(loc.ServiceType, loc.ResourceName)
79✔
198
        apiIdentity := p.Cluster.BehaviorForResource(loc.ServiceType, loc.ResourceName).IdentityInV1API
79✔
199
        return limesresources.Commitment{
79✔
200
                ID:               int64(c.ID),
79✔
201
                ServiceType:      apiIdentity.ServiceType,
79✔
202
                ResourceName:     apiIdentity.Name,
79✔
203
                AvailabilityZone: loc.AvailabilityZone,
79✔
204
                Amount:           c.Amount,
79✔
205
                Unit:             resInfo.Unit,
79✔
206
                Duration:         c.Duration,
79✔
207
                CreatedAt:        limes.UnixEncodedTime{Time: c.CreatedAt},
79✔
208
                CreatorUUID:      c.CreatorUUID,
79✔
209
                CreatorName:      c.CreatorName,
79✔
210
                CanBeDeleted:     p.canDeleteCommitment(token, c),
79✔
211
                ConfirmBy:        maybeUnixEncodedTime(c.ConfirmBy),
79✔
212
                ConfirmedAt:      maybeUnixEncodedTime(c.ConfirmedAt),
79✔
213
                ExpiresAt:        limes.UnixEncodedTime{Time: c.ExpiresAt},
79✔
214
                TransferStatus:   c.TransferStatus,
79✔
215
                TransferToken:    c.TransferToken,
79✔
216
        }
79✔
217
}
79✔
218

219
func (p *v1Provider) parseAndValidateCommitmentRequest(w http.ResponseWriter, r *http.Request) (*limesresources.CommitmentRequest, *datamodel.AZResourceLocation, *core.ResourceBehavior) {
42✔
220
        // parse request
42✔
221
        var parseTarget struct {
42✔
222
                Request limesresources.CommitmentRequest `json:"commitment"`
42✔
223
        }
42✔
224
        if !RequireJSON(w, r, &parseTarget) {
43✔
225
                return nil, nil, nil
1✔
226
        }
1✔
227
        req := parseTarget.Request
41✔
228

41✔
229
        // validate request
41✔
230
        nm := core.BuildResourceNameMapping(p.Cluster)
41✔
231
        dbServiceType, dbResourceName, ok := nm.MapFromV1API(req.ServiceType, req.ResourceName)
41✔
232
        if !ok {
43✔
233
                msg := fmt.Sprintf("no such service and/or resource: %s/%s", req.ServiceType, req.ResourceName)
2✔
234
                http.Error(w, msg, http.StatusUnprocessableEntity)
2✔
235
                return nil, nil, nil
2✔
236
        }
2✔
237
        behavior := p.Cluster.BehaviorForResource(dbServiceType, dbResourceName)
39✔
238
        resInfo := p.Cluster.InfoForResource(dbServiceType, dbResourceName)
39✔
239
        if len(behavior.CommitmentDurations) == 0 {
40✔
240
                http.Error(w, "commitments are not enabled for this resource", http.StatusUnprocessableEntity)
1✔
241
                return nil, nil, nil
1✔
242
        }
1✔
243
        if resInfo.Topology == liquid.FlatResourceTopology {
41✔
244
                if req.AvailabilityZone != limes.AvailabilityZoneAny {
4✔
245
                        http.Error(w, `resource does not accept AZ-aware commitments, so the AZ must be set to "any"`, http.StatusUnprocessableEntity)
1✔
246
                        return nil, nil, nil
1✔
247
                }
1✔
248
        } else {
35✔
249
                if !slices.Contains(p.Cluster.Config.AvailabilityZones, req.AvailabilityZone) {
39✔
250
                        http.Error(w, "no such availability zone", http.StatusUnprocessableEntity)
4✔
251
                        return nil, nil, nil
4✔
252
                }
4✔
253
        }
254
        if !slices.Contains(behavior.CommitmentDurations, req.Duration) {
34✔
255
                buf := must.Return(json.Marshal(behavior.CommitmentDurations)) // panic on error is acceptable here, marshals should never fail
1✔
256
                msg := "unacceptable commitment duration for this resource, acceptable values: " + string(buf)
1✔
257
                http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
258
                return nil, nil, nil
1✔
259
        }
1✔
260
        if req.Amount == 0 {
33✔
261
                http.Error(w, "amount of committed resource must be greater than zero", http.StatusUnprocessableEntity)
1✔
262
                return nil, nil, nil
1✔
263
        }
1✔
264

265
        loc := datamodel.AZResourceLocation{
31✔
266
                ServiceType:      dbServiceType,
31✔
267
                ResourceName:     dbResourceName,
31✔
268
                AvailabilityZone: req.AvailabilityZone,
31✔
269
        }
31✔
270
        return &req, &loc, &behavior
31✔
271
}
272

273
// CanConfirmNewProjectCommitment handles POST /v1/domains/:domain_id/projects/:project_id/commitments/can-confirm.
274
func (p *v1Provider) CanConfirmNewProjectCommitment(w http.ResponseWriter, r *http.Request) {
7✔
275
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/can-confirm")
7✔
276
        token := p.CheckToken(r)
7✔
277
        if !token.Require(w, "project:edit") {
7✔
278
                return
×
279
        }
×
280
        dbDomain := p.FindDomainFromRequest(w, r)
7✔
281
        if dbDomain == nil {
7✔
282
                return
×
283
        }
×
284
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
7✔
285
        if dbProject == nil {
7✔
286
                return
×
287
        }
×
288
        req, loc, behavior := p.parseAndValidateCommitmentRequest(w, r)
7✔
289
        if req == nil {
7✔
290
                return
×
291
        }
×
292

293
        var (
7✔
294
                resourceID                db.ProjectResourceID
7✔
295
                azResourceID              db.ProjectAZResourceID
7✔
296
                resourceAllowsCommitments bool
7✔
297
        )
7✔
298
        err := p.DB.QueryRow(findProjectAZResourceIDByLocationQuery, dbProject.ID, loc.ServiceType, loc.ResourceName, loc.AvailabilityZone).
7✔
299
                Scan(&resourceID, &azResourceID, &resourceAllowsCommitments)
7✔
300
        if respondwith.ErrorText(w, err) {
7✔
301
                return
×
302
        }
×
303
        if !resourceAllowsCommitments {
7✔
304
                msg := fmt.Sprintf("resource %s/%s is not enabled in this project", req.ServiceType, req.ResourceName)
×
305
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
306
                return
×
307
        }
×
308
        _ = azResourceID // returned by the above query, but not used in this function
7✔
309

7✔
310
        // commitments can never be confirmed immediately if we are before the min_confirm_date
7✔
311
        now := p.timeNow()
7✔
312
        if behavior.CommitmentMinConfirmDate != nil && behavior.CommitmentMinConfirmDate.After(now) {
8✔
313
                respondwith.JSON(w, http.StatusOK, map[string]bool{"result": false})
1✔
314
                return
1✔
315
        }
1✔
316

317
        // check for committable capacity
318
        result, err := datamodel.CanConfirmNewCommitment(*loc, resourceID, req.Amount, p.Cluster, p.DB)
6✔
319
        if respondwith.ErrorText(w, err) {
6✔
320
                return
×
321
        }
×
322
        respondwith.JSON(w, http.StatusOK, map[string]bool{"result": result})
6✔
323
}
324

325
// CreateProjectCommitment handles POST /v1/domains/:domain_id/projects/:project_id/commitments/new.
326
func (p *v1Provider) CreateProjectCommitment(w http.ResponseWriter, r *http.Request) {
38✔
327
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/new")
38✔
328
        token := p.CheckToken(r)
38✔
329
        if !token.Require(w, "project:edit") {
39✔
330
                return
1✔
331
        }
1✔
332
        dbDomain := p.FindDomainFromRequest(w, r)
37✔
333
        if dbDomain == nil {
38✔
334
                return
1✔
335
        }
1✔
336
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
36✔
337
        if dbProject == nil {
37✔
338
                return
1✔
339
        }
1✔
340
        req, loc, behavior := p.parseAndValidateCommitmentRequest(w, r)
35✔
341
        if req == nil {
46✔
342
                return
11✔
343
        }
11✔
344

345
        var (
24✔
346
                resourceID                db.ProjectResourceID
24✔
347
                azResourceID              db.ProjectAZResourceID
24✔
348
                resourceAllowsCommitments bool
24✔
349
        )
24✔
350
        err := p.DB.QueryRow(findProjectAZResourceIDByLocationQuery, dbProject.ID, loc.ServiceType, loc.ResourceName, loc.AvailabilityZone).
24✔
351
                Scan(&resourceID, &azResourceID, &resourceAllowsCommitments)
24✔
352
        if respondwith.ErrorText(w, err) {
24✔
353
                return
×
354
        }
×
355
        if !resourceAllowsCommitments {
25✔
356
                msg := fmt.Sprintf("resource %s/%s is not enabled in this project", req.ServiceType, req.ResourceName)
1✔
357
                http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
358
                return
1✔
359
        }
1✔
360

361
        // if given, confirm_by must definitely after time.Now(), and also after the MinConfirmDate if configured
362
        now := p.timeNow()
23✔
363
        if req.ConfirmBy != nil && req.ConfirmBy.Before(now) {
24✔
364
                http.Error(w, "confirm_by must not be set in the past", http.StatusUnprocessableEntity)
1✔
365
                return
1✔
366
        }
1✔
367
        if minConfirmBy := behavior.CommitmentMinConfirmDate; minConfirmBy != nil && minConfirmBy.After(now) {
27✔
368
                if req.ConfirmBy == nil || req.ConfirmBy.Before(*minConfirmBy) {
6✔
369
                        msg := "this commitment needs a `confirm_by` timestamp at or after " + behavior.CommitmentMinConfirmDate.Format(time.RFC3339)
1✔
370
                        http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
371
                        return
1✔
372
                }
1✔
373
        }
374

375
        // we want to validate committable capacity in the same transaction that creates the commitment
376
        tx, err := p.DB.Begin()
21✔
377
        if respondwith.ErrorText(w, err) {
21✔
378
                return
×
379
        }
×
380
        defer sqlext.RollbackUnlessCommitted(tx)
21✔
381

21✔
382
        // prepare commitment
21✔
383
        confirmBy := maybeUnpackUnixEncodedTime(req.ConfirmBy)
21✔
384
        dbCommitment := db.ProjectCommitment{
21✔
385
                AZResourceID: azResourceID,
21✔
386
                Amount:       req.Amount,
21✔
387
                Duration:     req.Duration,
21✔
388
                CreatedAt:    now,
21✔
389
                CreatorUUID:  token.UserUUID(),
21✔
390
                CreatorName:  fmt.Sprintf("%s@%s", token.UserName(), token.UserDomainName()),
21✔
391
                ConfirmBy:    confirmBy,
21✔
392
                ConfirmedAt:  nil, // may be set below
21✔
393
                ExpiresAt:    req.Duration.AddTo(unwrapOrDefault(confirmBy, now)),
21✔
394
        }
21✔
395
        if req.ConfirmBy == nil {
36✔
396
                // if not planned for confirmation in the future, confirm immediately (or fail)
15✔
397
                ok, err := datamodel.CanConfirmNewCommitment(*loc, resourceID, req.Amount, p.Cluster, tx)
15✔
398
                if respondwith.ErrorText(w, err) {
15✔
399
                        return
×
UNCOV
400
                }
×
401
                if !ok {
15✔
UNCOV
402
                        http.Error(w, "not enough capacity available for immediate confirmation", http.StatusConflict)
×
UNCOV
403
                        return
×
UNCOV
404
                }
×
405
                dbCommitment.ConfirmedAt = &now
15✔
406
                dbCommitment.State = db.CommitmentStateActive
15✔
407
        } else {
6✔
408
                dbCommitment.State = db.CommitmentStatePlanned
6✔
409
        }
6✔
410

411
        // create commitment
412
        err = tx.Insert(&dbCommitment)
21✔
413
        if respondwith.ErrorText(w, err) {
21✔
UNCOV
414
                return
×
UNCOV
415
        }
×
416
        err = tx.Commit()
21✔
417
        if respondwith.ErrorText(w, err) {
21✔
UNCOV
418
                return
×
UNCOV
419
        }
×
420
        p.auditor.Record(audittools.Event{
21✔
421
                Time:       now,
21✔
422
                Request:    r,
21✔
423
                User:       token,
21✔
424
                ReasonCode: http.StatusCreated,
21✔
425
                Action:     cadf.CreateAction,
21✔
426
                Target: commitmentEventTarget{
21✔
427
                        DomainID:    dbDomain.UUID,
21✔
428
                        DomainName:  dbDomain.Name,
21✔
429
                        ProjectID:   dbProject.UUID,
21✔
430
                        ProjectName: dbProject.Name,
21✔
431
                        Commitments: []limesresources.Commitment{p.convertCommitmentToDisplayForm(dbCommitment, *loc, token)},
21✔
432
                },
21✔
433
        })
21✔
434

21✔
435
        // if the commitment is immediately confirmed, trigger a capacity scrape in
21✔
436
        // order to ApplyComputedProjectQuotas based on the new commitment
21✔
437
        if dbCommitment.ConfirmedAt != nil {
36✔
438
                _, err := p.DB.Exec(forceImmediateCapacityScrapeQuery, now, loc.ServiceType, loc.ResourceName)
15✔
439
                if respondwith.ErrorText(w, err) {
15✔
UNCOV
440
                        return
×
UNCOV
441
                }
×
442
        }
443

444
        // display the possibly confirmed commitment to the user
445
        err = p.DB.SelectOne(&dbCommitment, `SELECT * FROM project_commitments WHERE id = $1`, dbCommitment.ID)
21✔
446
        if respondwith.ErrorText(w, err) {
21✔
447
                return
×
448
        }
×
449

450
        c := p.convertCommitmentToDisplayForm(dbCommitment, *loc, token)
21✔
451
        respondwith.JSON(w, http.StatusCreated, map[string]any{"commitment": c})
21✔
452
}
453

454
// MergeProjectCommitments handles POST /v1/domains/:domain_id/projects/:project_id/commitments/merge.
455
func (p *v1Provider) MergeProjectCommitments(w http.ResponseWriter, r *http.Request) {
12✔
456
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/merge")
12✔
457
        token := p.CheckToken(r)
12✔
458
        if !token.Require(w, "project:edit") {
13✔
459
                return
1✔
460
        }
1✔
461
        dbDomain := p.FindDomainFromRequest(w, r)
11✔
462
        if dbDomain == nil {
12✔
463
                return
1✔
464
        }
1✔
465
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
10✔
466
        if dbProject == nil {
11✔
467
                return
1✔
468
        }
1✔
469
        var parseTarget struct {
9✔
470
                CommitmentIDs []db.ProjectCommitmentID `json:"commitment_ids"`
9✔
471
        }
9✔
472
        if !RequireJSON(w, r, &parseTarget) {
9✔
NEW
473
                return
×
NEW
474
        }
×
475
        commitmentIDs := parseTarget.CommitmentIDs
9✔
476
        if len(commitmentIDs) < 2 {
10✔
477
                http.Error(w, fmt.Sprintf("merging requires at least two commitments, but %d were given", len(commitmentIDs)), http.StatusBadRequest)
1✔
478
                return
1✔
479
        }
1✔
480

481
        // Load commitments
482
        dbCommitments := make([]db.ProjectCommitment, len(commitmentIDs))
8✔
483
        for i, commitmentID := range commitmentIDs {
24✔
484
                err := p.DB.SelectOne(&dbCommitments[i], findProjectCommitmentByIDQuery, commitmentID, dbProject.ID)
16✔
485
                if errors.Is(err, sql.ErrNoRows) {
17✔
486
                        http.Error(w, "no such commitment", http.StatusNotFound)
1✔
487
                        return
1✔
488
                } else if respondwith.ErrorText(w, err) {
16✔
NEW
489
                        return
×
NEW
490
                }
×
491
        }
492

493
        // Verify that all commitments agree on resource and AZ and are active
494
        azResourceID := dbCommitments[0].AZResourceID
7✔
495
        for _, dbCommitment := range dbCommitments {
21✔
496
                if dbCommitment.AZResourceID != azResourceID {
16✔
497
                        http.Error(w, "all commitments must be on the same resource and AZ", http.StatusConflict)
2✔
498
                        return
2✔
499
                }
2✔
500
                if dbCommitment.State != db.CommitmentStateActive {
16✔
501
                        http.Error(w, "only active commits can be merged", http.StatusConflict)
4✔
502
                        return
4✔
503
                }
4✔
504
        }
505

506
        var loc datamodel.AZResourceLocation
1✔
507
        err := p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, azResourceID).
1✔
508
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
1✔
509
        if errors.Is(err, sql.ErrNoRows) {
1✔
NEW
510
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
NEW
511
                return
×
512
        } else if respondwith.ErrorText(w, err) {
1✔
NEW
513
                return
×
NEW
514
        }
×
515

516
        // Start transaction for creating new commitment and marking merged commitments as superseded
517
        tx, err := p.DB.Begin()
1✔
518
        if respondwith.ErrorText(w, err) {
1✔
NEW
519
                return
×
NEW
520
        }
×
521
        defer sqlext.RollbackUnlessCommitted(tx)
1✔
522

1✔
523
        // Create merged template
1✔
524
        now := p.timeNow()
1✔
525
        dbMergedCommitment := db.ProjectCommitment{
1✔
526
                AZResourceID: azResourceID,
1✔
527
                Amount:       0,                                   // overwritten below
1✔
528
                Duration:     limesresources.CommitmentDuration{}, // overwritten below
1✔
529
                CreatedAt:    now,
1✔
530
                CreatorUUID:  token.UserUUID(),
1✔
531
                CreatorName:  fmt.Sprintf("%s@%s", token.UserName(), token.UserDomainName()),
1✔
532
                ConfirmedAt:  &now,
1✔
533
                ExpiresAt:    time.Time{}, // overwritten below
1✔
534
                State:        db.CommitmentStateActive,
1✔
535
        }
1✔
536

1✔
537
        // Fill amount and latest expiration date
1✔
538
        for _, dbCommitment := range dbCommitments {
3✔
539
                dbMergedCommitment.Amount += dbCommitment.Amount
2✔
540
                if dbCommitment.ExpiresAt.After(dbMergedCommitment.ExpiresAt) {
4✔
541
                        dbMergedCommitment.ExpiresAt = dbCommitment.ExpiresAt
2✔
542
                        dbMergedCommitment.Duration = dbCommitment.Duration
2✔
543
                }
2✔
544
        }
545

546
        // Insert into database
547
        err = tx.Insert(&dbMergedCommitment)
1✔
548
        if respondwith.ErrorText(w, err) {
1✔
NEW
549
                return
×
NEW
550
        }
×
551

552
        // Mark merged commits as superseded
553
        for _, dbCommitment := range dbCommitments {
3✔
554
                dbCommitment.SupersededAt = &now
2✔
555
                dbCommitment.SuccessorID = &dbMergedCommitment.ID
2✔
556
                dbCommitment.State = db.CommitmentStateSuperseded
2✔
557
                _, err = tx.Update(&dbCommitment)
2✔
558
                if respondwith.ErrorText(w, err) {
2✔
NEW
559
                        return
×
NEW
560
                }
×
561
        }
562

563
        err = tx.Commit()
1✔
564
        if respondwith.ErrorText(w, err) {
1✔
NEW
565
                return
×
NEW
566
        }
×
567

568
        c := p.convertCommitmentToDisplayForm(dbMergedCommitment, loc, token)
1✔
569
        auditEvent := commitmentEventTarget{
1✔
570
                DomainID:    dbDomain.UUID,
1✔
571
                DomainName:  dbDomain.Name,
1✔
572
                ProjectID:   dbProject.UUID,
1✔
573
                ProjectName: dbProject.Name,
1✔
574
                Commitments: []limesresources.Commitment{c},
1✔
575
        }
1✔
576
        auditEvent.SupersededCommitments = liquids.PointerTo(make([]limesresources.Commitment, len(dbCommitments)))
1✔
577
        for _, dbCommitment := range dbCommitments {
3✔
578
                *auditEvent.SupersededCommitments = append(*auditEvent.SupersededCommitments, p.convertCommitmentToDisplayForm(dbCommitment, loc, token))
2✔
579
        }
2✔
580
        p.auditor.Record(audittools.Event{
1✔
581
                Time:       p.timeNow(),
1✔
582
                Request:    r,
1✔
583
                User:       token,
1✔
584
                ReasonCode: http.StatusAccepted,
1✔
585
                Action:     cadf.UpdateAction,
1✔
586
                Target:     auditEvent,
1✔
587
        })
1✔
588

1✔
589
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
1✔
590
}
591

592
// DeleteProjectCommitment handles DELETE /v1/domains/:domain_id/projects/:project_id/commitments/:id.
593
func (p *v1Provider) DeleteProjectCommitment(w http.ResponseWriter, r *http.Request) {
8✔
594
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/:id")
8✔
595
        token := p.CheckToken(r)
8✔
596
        if !token.Require(w, "project:edit") { //NOTE: There is a more specific AuthZ check further down below.
8✔
597
                return
×
598
        }
×
599
        dbDomain := p.FindDomainFromRequest(w, r)
8✔
600
        if dbDomain == nil {
9✔
601
                return
1✔
602
        }
1✔
603
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
7✔
604
        if dbProject == nil {
8✔
605
                return
1✔
606
        }
1✔
607

608
        // load commitment
609
        var dbCommitment db.ProjectCommitment
6✔
610
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, mux.Vars(r)["id"], dbProject.ID)
6✔
611
        if errors.Is(err, sql.ErrNoRows) {
7✔
612
                http.Error(w, "no such commitment", http.StatusNotFound)
1✔
613
                return
1✔
614
        } else if respondwith.ErrorText(w, err) {
6✔
UNCOV
615
                return
×
UNCOV
616
        }
×
617
        var loc datamodel.AZResourceLocation
5✔
618
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
5✔
619
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
5✔
620
        if errors.Is(err, sql.ErrNoRows) {
5✔
621
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
UNCOV
622
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
UNCOV
623
                return
×
624
        } else if respondwith.ErrorText(w, err) {
5✔
UNCOV
625
                return
×
UNCOV
626
        }
×
627

628
        // check authorization for this specific commitment
629
        if !p.canDeleteCommitment(token, dbCommitment) {
6✔
630
                http.Error(w, "Forbidden", http.StatusForbidden)
1✔
631
                return
1✔
632
        }
1✔
633

634
        // perform deletion
635
        _, err = p.DB.Delete(&dbCommitment)
4✔
636
        if respondwith.ErrorText(w, err) {
4✔
UNCOV
637
                return
×
638
        }
×
639
        p.auditor.Record(audittools.Event{
4✔
640
                Time:       p.timeNow(),
4✔
641
                Request:    r,
4✔
642
                User:       token,
4✔
643
                ReasonCode: http.StatusNoContent,
4✔
644
                Action:     cadf.DeleteAction,
4✔
645
                Target: commitmentEventTarget{
4✔
646
                        DomainID:    dbDomain.UUID,
4✔
647
                        DomainName:  dbDomain.Name,
4✔
648
                        ProjectID:   dbProject.UUID,
4✔
649
                        ProjectName: dbProject.Name,
4✔
650
                        Commitments: []limesresources.Commitment{p.convertCommitmentToDisplayForm(dbCommitment, loc, token)},
4✔
651
                },
4✔
652
        })
4✔
653

4✔
654
        w.WriteHeader(http.StatusNoContent)
4✔
655
}
656

657
func (p *v1Provider) canDeleteCommitment(token *gopherpolicy.Token, commitment db.ProjectCommitment) bool {
84✔
658
        // up to 24 hours after creation of fresh commitments, future commitments can still be deleted by their creators
84✔
659
        if commitment.State == db.CommitmentStatePlanned || commitment.State == db.CommitmentStatePending || commitment.State == db.CommitmentStateActive {
168✔
660
                if commitment.PredecessorID == nil && p.timeNow().Before(commitment.CreatedAt.Add(24*time.Hour)) {
148✔
661
                        if token.Check("project:edit") {
128✔
662
                                return true
64✔
663
                        }
64✔
664
                }
665
        }
666

667
        // afterwards, a more specific permission is required to delete it
668
        //
669
        // This protects cloud admins making capacity planning decisions based on future commitments
670
        // from having their forecasts ruined by project admins suffering from buyer's remorse.
671
        return token.Check("project:uncommit")
20✔
672
}
673

674
// StartCommitmentTransfer handles POST /v1/domains/:id/projects/:id/commitments/:id/start-transfer
675
func (p *v1Provider) StartCommitmentTransfer(w http.ResponseWriter, r *http.Request) {
8✔
676
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/:id/start-transfer")
8✔
677
        token := p.CheckToken(r)
8✔
678
        if !token.Require(w, "project:edit") {
8✔
UNCOV
679
                return
×
UNCOV
680
        }
×
681
        dbDomain := p.FindDomainFromRequest(w, r)
8✔
682
        if dbDomain == nil {
8✔
683
                return
×
684
        }
×
685
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
8✔
686
        if dbProject == nil {
8✔
UNCOV
687
                return
×
UNCOV
688
        }
×
689
        // TODO: eventually migrate this struct into go-api-declarations
690
        var parseTarget struct {
8✔
691
                Request struct {
8✔
692
                        Amount         uint64                                  `json:"amount"`
8✔
693
                        TransferStatus limesresources.CommitmentTransferStatus `json:"transfer_status,omitempty"`
8✔
694
                } `json:"commitment"`
8✔
695
        }
8✔
696
        if !RequireJSON(w, r, &parseTarget) {
8✔
UNCOV
697
                return
×
UNCOV
698
        }
×
699
        req := parseTarget.Request
8✔
700

8✔
701
        if req.TransferStatus != limesresources.CommitmentTransferStatusUnlisted && req.TransferStatus != limesresources.CommitmentTransferStatusPublic {
8✔
UNCOV
702
                http.Error(w, fmt.Sprintf("Invalid transfer_status code. Must be %s or %s.", limesresources.CommitmentTransferStatusUnlisted, limesresources.CommitmentTransferStatusPublic), http.StatusBadRequest)
×
UNCOV
703
                return
×
704
        }
×
705

706
        if req.Amount <= 0 {
9✔
707
                http.Error(w, "delivered amount needs to be a positive value.", http.StatusBadRequest)
1✔
708
                return
1✔
709
        }
1✔
710

711
        // load commitment
712
        var dbCommitment db.ProjectCommitment
7✔
713
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, mux.Vars(r)["id"], dbProject.ID)
7✔
714
        if errors.Is(err, sql.ErrNoRows) {
7✔
UNCOV
715
                http.Error(w, "no such commitment", http.StatusNotFound)
×
UNCOV
716
                return
×
717
        } else if respondwith.ErrorText(w, err) {
7✔
UNCOV
718
                return
×
UNCOV
719
        }
×
720

721
        // Mark whole commitment or a newly created, splitted one as transferrable.
722
        tx, err := p.DB.Begin()
7✔
723
        if respondwith.ErrorText(w, err) {
7✔
UNCOV
724
                return
×
UNCOV
725
        }
×
726
        defer sqlext.RollbackUnlessCommitted(tx)
7✔
727
        transferToken := p.generateTransferToken()
7✔
728

7✔
729
        // Deny requests with a greater amount than the commitment.
7✔
730
        if req.Amount > dbCommitment.Amount {
8✔
731
                http.Error(w, "delivered amount exceeds the commitment amount.", http.StatusBadRequest)
1✔
732
                return
1✔
733
        }
1✔
734

735
        if req.Amount == dbCommitment.Amount {
10✔
736
                dbCommitment.TransferStatus = req.TransferStatus
4✔
737
                dbCommitment.TransferToken = &transferToken
4✔
738
                _, err = tx.Update(&dbCommitment)
4✔
739
                if respondwith.ErrorText(w, err) {
4✔
740
                        return
×
741
                }
×
742
        } else {
2✔
743
                now := p.timeNow()
2✔
744
                transferAmount := req.Amount
2✔
745
                remainingAmount := dbCommitment.Amount - req.Amount
2✔
746
                transferCommitment := p.buildSplitCommitment(dbCommitment, transferAmount)
2✔
747
                transferCommitment.TransferStatus = req.TransferStatus
2✔
748
                transferCommitment.TransferToken = &transferToken
2✔
749
                remainingCommitment := p.buildSplitCommitment(dbCommitment, remainingAmount)
2✔
750
                err = tx.Insert(&transferCommitment)
2✔
751
                if respondwith.ErrorText(w, err) {
2✔
UNCOV
752
                        return
×
UNCOV
753
                }
×
754
                err = tx.Insert(&remainingCommitment)
2✔
755
                if respondwith.ErrorText(w, err) {
2✔
UNCOV
756
                        return
×
UNCOV
757
                }
×
758
                dbCommitment.State = db.CommitmentStateSuperseded
2✔
759
                dbCommitment.SupersededAt = &now
2✔
760
                _, err = tx.Update(&dbCommitment)
2✔
761
                if respondwith.ErrorText(w, err) {
2✔
UNCOV
762
                        return
×
UNCOV
763
                }
×
764
                dbCommitment = transferCommitment
2✔
765
        }
766
        err = tx.Commit()
6✔
767
        if respondwith.ErrorText(w, err) {
6✔
UNCOV
768
                return
×
UNCOV
769
        }
×
770

771
        var loc datamodel.AZResourceLocation
6✔
772
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
6✔
773
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
6✔
774
        if errors.Is(err, sql.ErrNoRows) {
6✔
UNCOV
775
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
UNCOV
776
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
777
                return
×
778
        } else if respondwith.ErrorText(w, err) {
6✔
779
                return
×
780
        }
×
781

782
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token)
6✔
783
        p.auditor.Record(audittools.Event{
6✔
784
                Time:       p.timeNow(),
6✔
785
                Request:    r,
6✔
786
                User:       token,
6✔
787
                ReasonCode: http.StatusAccepted,
6✔
788
                Action:     cadf.UpdateAction,
6✔
789
                Target: commitmentEventTarget{
6✔
790
                        DomainID:    dbDomain.UUID,
6✔
791
                        DomainName:  dbDomain.Name,
6✔
792
                        ProjectID:   dbProject.UUID,
6✔
793
                        ProjectName: dbProject.Name,
6✔
794
                        Commitments: []limesresources.Commitment{c},
6✔
795
                        // TODO: if commitment was split, log all participating commitment objects (incl. the SupersededCommitment)
6✔
796
                },
6✔
797
        })
6✔
798
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
6✔
799
}
800

801
func (p *v1Provider) buildSplitCommitment(dbCommitment db.ProjectCommitment, amount uint64) db.ProjectCommitment {
7✔
802
        now := p.timeNow()
7✔
803
        return db.ProjectCommitment{
7✔
804
                AZResourceID:  dbCommitment.AZResourceID,
7✔
805
                Amount:        amount,
7✔
806
                Duration:      dbCommitment.Duration,
7✔
807
                CreatedAt:     now,
7✔
808
                CreatorUUID:   dbCommitment.CreatorUUID,
7✔
809
                CreatorName:   dbCommitment.CreatorName,
7✔
810
                ConfirmBy:     dbCommitment.ConfirmBy,
7✔
811
                ConfirmedAt:   dbCommitment.ConfirmedAt,
7✔
812
                ExpiresAt:     dbCommitment.ExpiresAt,
7✔
813
                PredecessorID: &dbCommitment.ID,
7✔
814
                State:         dbCommitment.State,
7✔
815
        }
7✔
816
}
7✔
817

818
func (p *v1Provider) buildConvertedCommitment(dbCommitment db.ProjectCommitment, azResourceID db.ProjectAZResourceID, amount uint64) db.ProjectCommitment {
2✔
819
        commitment := p.buildSplitCommitment(dbCommitment, amount)
2✔
820
        commitment.AZResourceID = azResourceID
2✔
821
        return commitment
2✔
822
}
2✔
823

824
// GetCommitmentByTransferToken handles GET /v1/commitments/{token}
825
func (p *v1Provider) GetCommitmentByTransferToken(w http.ResponseWriter, r *http.Request) {
2✔
826
        httpapi.IdentifyEndpoint(r, "/v1/commitments/:token")
2✔
827
        token := p.CheckToken(r)
2✔
828
        if !token.Require(w, "cluster:show_basic") {
2✔
UNCOV
829
                return
×
UNCOV
830
        }
×
831
        transferToken := mux.Vars(r)["token"]
2✔
832

2✔
833
        // The token column is a unique key, so we expect only one result.
2✔
834
        var dbCommitment db.ProjectCommitment
2✔
835
        err := p.DB.SelectOne(&dbCommitment, findCommitmentByTransferToken, transferToken)
2✔
836
        if errors.Is(err, sql.ErrNoRows) {
3✔
837
                http.Error(w, "no matching commitment found.", http.StatusNotFound)
1✔
838
                return
1✔
839
        } else if respondwith.ErrorText(w, err) {
2✔
840
                return
×
UNCOV
841
        }
×
842

843
        var loc datamodel.AZResourceLocation
1✔
844
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
1✔
845
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
1✔
846
        if errors.Is(err, sql.ErrNoRows) {
1✔
847
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
848
                http.Error(w, "location data not found.", http.StatusNotFound)
×
UNCOV
849
                return
×
850
        } else if respondwith.ErrorText(w, err) {
1✔
851
                return
×
852
        }
×
853

854
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token)
1✔
855
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
1✔
856
}
857

858
// TransferCommitment handles POST /v1/domains/{domain_id}/projects/{project_id}/transfer-commitment/{id}?token={token}
859
func (p *v1Provider) TransferCommitment(w http.ResponseWriter, r *http.Request) {
5✔
860
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/transfer-commitment/:id")
5✔
861
        token := p.CheckToken(r)
5✔
862
        if !token.Require(w, "project:edit") {
5✔
UNCOV
863
                return
×
UNCOV
864
        }
×
865
        transferToken := r.Header.Get("Transfer-Token")
5✔
866
        if transferToken == "" {
6✔
867
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
1✔
868
                return
1✔
869
        }
1✔
870
        commitmentID := mux.Vars(r)["id"]
4✔
871
        if commitmentID == "" {
4✔
872
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
×
873
                return
×
874
        }
×
875
        dbDomain := p.FindDomainFromRequest(w, r)
4✔
876
        if dbDomain == nil {
4✔
877
                return
×
878
        }
×
879
        targetProject := p.FindProjectFromRequest(w, r, dbDomain)
4✔
880
        if targetProject == nil {
4✔
881
                return
×
882
        }
×
883

884
        // find commitment by transfer_token
885
        var dbCommitment db.ProjectCommitment
4✔
886
        err := p.DB.SelectOne(&dbCommitment, getCommitmentWithMatchingTransferTokenQuery, commitmentID, transferToken)
4✔
887
        if errors.Is(err, sql.ErrNoRows) {
5✔
888
                http.Error(w, "no matching commitment found", http.StatusNotFound)
1✔
889
                return
1✔
890
        } else if respondwith.ErrorText(w, err) {
4✔
UNCOV
891
                return
×
UNCOV
892
        }
×
893

894
        var loc datamodel.AZResourceLocation
3✔
895
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
3✔
896
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
3✔
897
        if errors.Is(err, sql.ErrNoRows) {
3✔
UNCOV
898
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
UNCOV
899
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
UNCOV
900
                return
×
901
        } else if respondwith.ErrorText(w, err) {
3✔
UNCOV
902
                return
×
UNCOV
903
        }
×
904

905
        // get target service and AZ resource
906
        var (
3✔
907
                sourceResourceID   db.ProjectResourceID
3✔
908
                targetResourceID   db.ProjectResourceID
3✔
909
                targetAZResourceID db.ProjectAZResourceID
3✔
910
        )
3✔
911
        err = p.DB.QueryRow(findTargetAZResourceIDBySourceIDQuery, dbCommitment.AZResourceID, targetProject.ID).
3✔
912
                Scan(&sourceResourceID, &targetResourceID, &targetAZResourceID)
3✔
913
        if respondwith.ErrorText(w, err) {
3✔
914
                return
×
915
        }
×
916

917
        // validate that we have enough committable capacity on the receiving side
918
        tx, err := p.DB.Begin()
3✔
919
        if respondwith.ErrorText(w, err) {
3✔
UNCOV
920
                return
×
UNCOV
921
        }
×
922
        defer sqlext.RollbackUnlessCommitted(tx)
3✔
923
        ok, err := datamodel.CanMoveExistingCommitment(dbCommitment.Amount, loc, sourceResourceID, targetResourceID, p.Cluster, tx)
3✔
924
        if respondwith.ErrorText(w, err) {
3✔
UNCOV
925
                return
×
UNCOV
926
        }
×
927
        if !ok {
4✔
928
                http.Error(w, "not enough committable capacity on the receiving side", http.StatusConflict)
1✔
929
                return
1✔
930
        }
1✔
931

932
        dbCommitment.TransferStatus = ""
2✔
933
        dbCommitment.TransferToken = nil
2✔
934
        dbCommitment.AZResourceID = targetAZResourceID
2✔
935
        _, err = tx.Update(&dbCommitment)
2✔
936
        if respondwith.ErrorText(w, err) {
2✔
UNCOV
937
                return
×
938
        }
×
939
        err = tx.Commit()
2✔
940
        if respondwith.ErrorText(w, err) {
2✔
UNCOV
941
                return
×
UNCOV
942
        }
×
943

944
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token)
2✔
945
        p.auditor.Record(audittools.Event{
2✔
946
                Time:       p.timeNow(),
2✔
947
                Request:    r,
2✔
948
                User:       token,
2✔
949
                ReasonCode: http.StatusAccepted,
2✔
950
                Action:     cadf.UpdateAction,
2✔
951
                Target: commitmentEventTarget{
2✔
952
                        DomainID:    dbDomain.UUID,
2✔
953
                        DomainName:  dbDomain.Name,
2✔
954
                        ProjectID:   targetProject.UUID,
2✔
955
                        ProjectName: targetProject.Name,
2✔
956
                        Commitments: []limesresources.Commitment{c},
2✔
957
                },
2✔
958
        })
2✔
959

2✔
960
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
2✔
961
}
962

963
// GetCommitmentConversion handles GET /v1/commitment-conversion/{service_type}/{resource_name}
964
func (p *v1Provider) GetCommitmentConversions(w http.ResponseWriter, r *http.Request) {
2✔
965
        httpapi.IdentifyEndpoint(r, "/v1/commitment-conversion/:service_type/:resource_name")
2✔
966
        token := p.CheckToken(r)
2✔
967
        if !token.Require(w, "cluster:show_basic") {
2✔
968
                return
×
969
        }
×
970

971
        // validate request
972
        vars := mux.Vars(r)
2✔
973
        nm := core.BuildResourceNameMapping(p.Cluster)
2✔
974
        sourceServiceType, sourceResourceName, exists := nm.MapFromV1API(
2✔
975
                limes.ServiceType(vars["service_type"]),
2✔
976
                limesresources.ResourceName(vars["resource_name"]),
2✔
977
        )
2✔
978
        if !exists {
2✔
UNCOV
979
                msg := fmt.Sprintf("no such service and/or resource: %s/%s", vars["service_type"], vars["resource_name"])
×
980
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
981
                return
×
UNCOV
982
        }
×
983
        sourceBehavior := p.Cluster.BehaviorForResource(sourceServiceType, sourceResourceName)
2✔
984
        sourceResInfo := p.Cluster.InfoForResource(sourceServiceType, sourceResourceName)
2✔
985

2✔
986
        // enumerate possible conversions
2✔
987
        conversions := make([]limesresources.CommitmentConversionRule, 0)
2✔
988
        for targetServiceType, quotaPlugin := range p.Cluster.QuotaPlugins {
8✔
989
                for targetResourceName, targetResInfo := range quotaPlugin.Resources() {
28✔
990
                        targetBehavior := p.Cluster.BehaviorForResource(targetServiceType, targetResourceName)
22✔
991
                        if targetBehavior.CommitmentConversion == (core.CommitmentConversion{}) {
30✔
992
                                continue
8✔
993
                        }
994
                        if sourceServiceType == targetServiceType && sourceResourceName == targetResourceName {
16✔
995
                                continue
2✔
996
                        }
997
                        if sourceResInfo.Unit != targetResInfo.Unit {
19✔
998
                                continue
7✔
999
                        }
1000
                        if sourceBehavior.CommitmentConversion.Identifier != targetBehavior.CommitmentConversion.Identifier {
6✔
1001
                                continue
1✔
1002
                        }
1003

1004
                        fromAmount, toAmount := p.getCommitmentConversionRate(sourceBehavior, targetBehavior)
4✔
1005
                        apiServiceType, apiResourceName, ok := nm.MapToV1API(targetServiceType, targetResourceName)
4✔
1006
                        if ok {
8✔
1007
                                conversions = append(conversions, limesresources.CommitmentConversionRule{
4✔
1008
                                        FromAmount:     fromAmount,
4✔
1009
                                        ToAmount:       toAmount,
4✔
1010
                                        TargetService:  apiServiceType,
4✔
1011
                                        TargetResource: apiResourceName,
4✔
1012
                                })
4✔
1013
                        }
4✔
1014
                }
1015
        }
1016

1017
        // use a defined sorting to ensure deterministic behavior in tests
1018
        slices.SortFunc(conversions, func(lhs, rhs limesresources.CommitmentConversionRule) int {
8✔
1019
                result := strings.Compare(string(lhs.TargetService), string(rhs.TargetService))
6✔
1020
                if result != 0 {
11✔
1021
                        return result
5✔
1022
                }
5✔
1023
                return strings.Compare(string(lhs.TargetResource), string(rhs.TargetResource))
1✔
1024
        })
1025

1026
        respondwith.JSON(w, http.StatusOK, map[string]any{"conversions": conversions})
2✔
1027
}
1028

1029
// ConvertCommitment handles POST /v1/domains/{domain_id}/projects/{project_id}/commitments/{commitment_id}/convert
1030
func (p *v1Provider) ConvertCommitment(w http.ResponseWriter, r *http.Request) {
9✔
1031
        httpapi.IdentifyEndpoint(r, "/v1/domains/:domain_id/projects/:project_id/commitments/:commitment_id/convert")
9✔
1032
        token := p.CheckToken(r)
9✔
1033
        if !token.Require(w, "project:edit") {
9✔
1034
                return
×
1035
        }
×
1036
        commitmentID := mux.Vars(r)["commitment_id"]
9✔
1037
        if commitmentID == "" {
9✔
UNCOV
1038
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
×
UNCOV
1039
                return
×
UNCOV
1040
        }
×
1041
        dbDomain := p.FindDomainFromRequest(w, r)
9✔
1042
        if dbDomain == nil {
9✔
UNCOV
1043
                return
×
UNCOV
1044
        }
×
1045
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
9✔
1046
        if dbProject == nil {
9✔
1047
                return
×
1048
        }
×
1049

1050
        // section: sourceBehavior
1051
        var dbCommitment db.ProjectCommitment
9✔
1052
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, commitmentID, dbProject.ID)
9✔
1053
        if errors.Is(err, sql.ErrNoRows) {
10✔
1054
                http.Error(w, "no such commitment", http.StatusNotFound)
1✔
1055
                return
1✔
1056
        } else if respondwith.ErrorText(w, err) {
9✔
UNCOV
1057
                return
×
UNCOV
1058
        }
×
1059
        var sourceLoc datamodel.AZResourceLocation
8✔
1060
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
8✔
1061
                Scan(&sourceLoc.ServiceType, &sourceLoc.ResourceName, &sourceLoc.AvailabilityZone)
8✔
1062
        if errors.Is(err, sql.ErrNoRows) {
8✔
UNCOV
1063
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
UNCOV
1064
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
UNCOV
1065
                return
×
1066
        } else if respondwith.ErrorText(w, err) {
8✔
UNCOV
1067
                return
×
UNCOV
1068
        }
×
1069
        sourceBehavior := p.Cluster.BehaviorForResource(sourceLoc.ServiceType, sourceLoc.ResourceName)
8✔
1070

8✔
1071
        // section: targetBehavior
8✔
1072
        var parseTarget struct {
8✔
1073
                Request struct {
8✔
1074
                        TargetService  limes.ServiceType           `json:"target_service"`
8✔
1075
                        TargetResource limesresources.ResourceName `json:"target_resource"`
8✔
1076
                        SourceAmount   uint64                      `json:"source_amount"`
8✔
1077
                        TargetAmount   uint64                      `json:"target_amount"`
8✔
1078
                } `json:"commitment"`
8✔
1079
        }
8✔
1080
        if !RequireJSON(w, r, &parseTarget) {
8✔
UNCOV
1081
                return
×
UNCOV
1082
        }
×
1083
        req := parseTarget.Request
8✔
1084
        nm := core.BuildResourceNameMapping(p.Cluster)
8✔
1085
        targetServiceType, targetResourceName, exists := nm.MapFromV1API(req.TargetService, req.TargetResource)
8✔
1086
        if !exists {
8✔
UNCOV
1087
                msg := fmt.Sprintf("no such service and/or resource: %s/%s", req.TargetService, req.TargetResource)
×
UNCOV
1088
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
UNCOV
1089
                return
×
UNCOV
1090
        }
×
1091
        targetBehavior := p.Cluster.BehaviorForResource(targetServiceType, targetResourceName)
8✔
1092
        if sourceLoc.ResourceName == targetResourceName && sourceLoc.ServiceType == targetServiceType {
9✔
1093
                http.Error(w, "conversion attempt to the same resource.", http.StatusConflict)
1✔
1094
                return
1✔
1095
        }
1✔
1096
        if len(targetBehavior.CommitmentDurations) == 0 {
7✔
UNCOV
1097
                msg := fmt.Sprintf("commitments are not enabled for resource %s/%s", req.TargetService, req.TargetResource)
×
UNCOV
1098
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
UNCOV
1099
                return
×
1100
        }
×
1101
        if sourceBehavior.CommitmentConversion.Identifier == "" || sourceBehavior.CommitmentConversion.Identifier != targetBehavior.CommitmentConversion.Identifier {
8✔
1102
                msg := fmt.Sprintf("commitment is not convertible into resource %s/%s", req.TargetService, req.TargetResource)
1✔
1103
                http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
1104
                return
1✔
1105
        }
1✔
1106

1107
        // section: conversion
1108
        if req.SourceAmount > dbCommitment.Amount {
6✔
1109
                msg := fmt.Sprintf("unprocessable source amount. provided: %v, commitment: %v", req.SourceAmount, dbCommitment.Amount)
×
1110
                http.Error(w, msg, http.StatusConflict)
×
UNCOV
1111
                return
×
UNCOV
1112
        }
×
1113
        fromAmount, toAmount := p.getCommitmentConversionRate(sourceBehavior, targetBehavior)
6✔
1114
        conversionAmount := (req.SourceAmount / fromAmount) * toAmount
6✔
1115
        remainderAmount := req.SourceAmount % fromAmount
6✔
1116
        if remainderAmount > 0 {
8✔
1117
                msg := fmt.Sprintf("amount: %v does not fit into conversion rate of: %v", req.SourceAmount, fromAmount)
2✔
1118
                http.Error(w, msg, http.StatusConflict)
2✔
1119
                return
2✔
1120
        }
2✔
1121
        if conversionAmount != req.TargetAmount {
5✔
1122
                msg := fmt.Sprintf("conversion mismatch. provided: %v, calculated: %v", req.TargetAmount, conversionAmount)
1✔
1123
                http.Error(w, msg, http.StatusConflict)
1✔
1124
                return
1✔
1125
        }
1✔
1126

1127
        tx, err := p.DB.Begin()
3✔
1128
        if respondwith.ErrorText(w, err) {
3✔
1129
                return
×
1130
        }
×
1131
        defer sqlext.RollbackUnlessCommitted(tx)
3✔
1132

3✔
1133
        var (
3✔
1134
                targetResourceID   db.ProjectResourceID
3✔
1135
                targetAZResourceID db.ProjectAZResourceID
3✔
1136
        )
3✔
1137
        err = p.DB.QueryRow(findTargetAZResourceByTargetProjectQuery, dbProject.ID, targetServiceType, targetResourceName, sourceLoc.AvailabilityZone).
3✔
1138
                Scan(&targetResourceID, &targetAZResourceID)
3✔
1139
        if respondwith.ErrorText(w, err) {
3✔
UNCOV
1140
                return
×
UNCOV
1141
        }
×
1142
        // defense in depth. ServiceType and ResourceName of source and target are already checked. Here it's possible to explicitly check the ID's.
1143
        if dbCommitment.AZResourceID == targetAZResourceID {
3✔
UNCOV
1144
                http.Error(w, "conversion attempt to the same resource.", http.StatusConflict)
×
UNCOV
1145
                return
×
UNCOV
1146
        }
×
1147
        targetLoc := datamodel.AZResourceLocation{
3✔
1148
                ServiceType:      targetServiceType,
3✔
1149
                ResourceName:     targetResourceName,
3✔
1150
                AvailabilityZone: sourceLoc.AvailabilityZone,
3✔
1151
        }
3✔
1152
        // The commitment at the source resource was already confirmed and checked.
3✔
1153
        // Therefore only the addition to the target resource has to be checked against.
3✔
1154
        if dbCommitment.ConfirmedAt != nil {
5✔
1155
                ok, err := datamodel.CanConfirmNewCommitment(targetLoc, targetResourceID, conversionAmount, p.Cluster, p.DB)
2✔
1156
                if respondwith.ErrorText(w, err) {
2✔
UNCOV
1157
                        return
×
UNCOV
1158
                }
×
1159
                if !ok {
3✔
1160
                        http.Error(w, "not enough capacity to confirm the commitment", http.StatusUnprocessableEntity)
1✔
1161
                        return
1✔
1162
                }
1✔
1163
        }
1164

1165
        auditEvent := commitmentEventTarget{
2✔
1166
                DomainID:              dbDomain.UUID,
2✔
1167
                DomainName:            dbDomain.Name,
2✔
1168
                ProjectID:             dbProject.UUID,
2✔
1169
                ProjectName:           dbProject.Name,
2✔
1170
                SupersededCommitments: liquids.PointerTo([]limesresources.Commitment{p.convertCommitmentToDisplayForm(dbCommitment, sourceLoc, token)}),
2✔
1171
        }
2✔
1172

2✔
1173
        remainingAmount := dbCommitment.Amount - req.SourceAmount
2✔
1174
        if remainingAmount > 0 {
3✔
1175
                remainingCommitment := p.buildSplitCommitment(dbCommitment, remainingAmount)
1✔
1176
                err = tx.Insert(&remainingCommitment)
1✔
1177
                if respondwith.ErrorText(w, err) {
1✔
1178
                        return
×
UNCOV
1179
                }
×
1180
                auditEvent.Commitments = append(auditEvent.Commitments,
1✔
1181
                        p.convertCommitmentToDisplayForm(remainingCommitment, sourceLoc, token),
1✔
1182
                )
1✔
1183
        }
1184

1185
        convertedCommitment := p.buildConvertedCommitment(dbCommitment, targetAZResourceID, conversionAmount)
2✔
1186
        err = tx.Insert(&convertedCommitment)
2✔
1187
        if respondwith.ErrorText(w, err) {
2✔
UNCOV
1188
                return
×
UNCOV
1189
        }
×
1190

1191
        // supersede the original commitment
1192
        now := p.timeNow()
2✔
1193
        dbCommitment.State = db.CommitmentStateSuperseded
2✔
1194
        dbCommitment.SupersededAt = &now
2✔
1195
        _, err = tx.Update(&dbCommitment)
2✔
1196
        if respondwith.ErrorText(w, err) {
2✔
UNCOV
1197
                return
×
UNCOV
1198
        }
×
1199

1200
        err = tx.Commit()
2✔
1201
        if respondwith.ErrorText(w, err) {
2✔
UNCOV
1202
                return
×
UNCOV
1203
        }
×
1204

1205
        c := p.convertCommitmentToDisplayForm(convertedCommitment, targetLoc, token)
2✔
1206
        auditEvent.Commitments = append([]limesresources.Commitment{c}, auditEvent.Commitments...)
2✔
1207
        p.auditor.Record(audittools.Event{
2✔
1208
                Time:       p.timeNow(),
2✔
1209
                Request:    r,
2✔
1210
                User:       token,
2✔
1211
                ReasonCode: http.StatusAccepted,
2✔
1212
                Action:     cadf.UpdateAction,
2✔
1213
                Target:     auditEvent,
2✔
1214
        })
2✔
1215

2✔
1216
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
2✔
1217
}
1218

1219
func (p *v1Provider) getCommitmentConversionRate(source, target core.ResourceBehavior) (fromAmount, toAmount uint64) {
10✔
1220
        divisor := GetGreatestCommonDivisor(source.CommitmentConversion.Weight, target.CommitmentConversion.Weight)
10✔
1221
        fromAmount = target.CommitmentConversion.Weight / divisor
10✔
1222
        toAmount = source.CommitmentConversion.Weight / divisor
10✔
1223
        return fromAmount, toAmount
10✔
1224
}
10✔
1225

1226
// ExtendCommitmentDuration handles POST /v1/domains/{domain_id}/projects/{project_id}/commitments/{commitment_id}/update-duration
1227
func (p *v1Provider) UpdateCommitmentDuration(w http.ResponseWriter, r *http.Request) {
6✔
1228
        httpapi.IdentifyEndpoint(r, "/v1/domains/:domain_id/projects/:project_id/commitments/:commitment_id/update-duration")
6✔
1229
        token := p.CheckToken(r)
6✔
1230
        if !token.Require(w, "project:edit") {
6✔
UNCOV
1231
                return
×
1232
        }
×
1233
        commitmentID := mux.Vars(r)["commitment_id"]
6✔
1234
        if commitmentID == "" {
6✔
1235
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
×
UNCOV
1236
                return
×
UNCOV
1237
        }
×
1238
        dbDomain := p.FindDomainFromRequest(w, r)
6✔
1239
        if dbDomain == nil {
6✔
UNCOV
1240
                return
×
1241
        }
×
1242
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
6✔
1243
        if dbProject == nil {
6✔
1244
                return
×
1245
        }
×
1246
        var Request struct {
6✔
1247
                Duration limesresources.CommitmentDuration `json:"duration"`
6✔
1248
        }
6✔
1249
        req := Request
6✔
1250
        if !RequireJSON(w, r, &req) {
6✔
UNCOV
1251
                return
×
UNCOV
1252
        }
×
1253

1254
        var dbCommitment db.ProjectCommitment
6✔
1255
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, commitmentID, dbProject.ID)
6✔
1256
        if errors.Is(err, sql.ErrNoRows) {
6✔
1257
                http.Error(w, "no such commitment", http.StatusNotFound)
×
1258
                return
×
1259
        } else if respondwith.ErrorText(w, err) {
6✔
UNCOV
1260
                return
×
UNCOV
1261
        }
×
1262

1263
        now := p.timeNow()
6✔
1264
        if dbCommitment.ExpiresAt.Before(now) || dbCommitment.ExpiresAt.Equal(now) {
7✔
1265
                http.Error(w, "unable to process expired commitment", http.StatusForbidden)
1✔
1266
                return
1✔
1267
        }
1✔
1268

1269
        if dbCommitment.State == db.CommitmentStateSuperseded {
6✔
1270
                msg := fmt.Sprintf("unable to operate on commitment with a state of %s", dbCommitment.State)
1✔
1271
                http.Error(w, msg, http.StatusForbidden)
1✔
1272
                return
1✔
1273
        }
1✔
1274

1275
        var loc datamodel.AZResourceLocation
4✔
1276
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
4✔
1277
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
4✔
1278
        if errors.Is(err, sql.ErrNoRows) {
4✔
UNCOV
1279
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
1280
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
1281
                return
×
1282
        } else if respondwith.ErrorText(w, err) {
4✔
UNCOV
1283
                return
×
UNCOV
1284
        }
×
1285
        behavior := p.Cluster.BehaviorForResource(loc.ServiceType, loc.ResourceName)
4✔
1286
        if !slices.Contains(behavior.CommitmentDurations, req.Duration) {
5✔
1287
                msg := fmt.Sprintf("provided duration: %s does not match the config %v", req.Duration, behavior.CommitmentDurations)
1✔
1288
                http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
1289
                return
1✔
1290
        }
1✔
1291

1292
        newExpiresAt := req.Duration.AddTo(unwrapOrDefault(dbCommitment.ConfirmBy, dbCommitment.CreatedAt))
3✔
1293
        if newExpiresAt.Before(dbCommitment.ExpiresAt) {
4✔
1294
                msg := fmt.Sprintf("duration change from %s to %s forbidden", dbCommitment.Duration, req.Duration)
1✔
1295
                http.Error(w, msg, http.StatusForbidden)
1✔
1296
                return
1✔
1297
        }
1✔
1298

1299
        dbCommitment.Duration = req.Duration
2✔
1300
        dbCommitment.ExpiresAt = newExpiresAt
2✔
1301
        _, err = p.DB.Update(&dbCommitment)
2✔
1302
        if respondwith.ErrorText(w, err) {
2✔
UNCOV
1303
                return
×
UNCOV
1304
        }
×
1305

1306
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token)
2✔
1307
        p.auditor.Record(audittools.Event{
2✔
1308
                Time:       p.timeNow(),
2✔
1309
                Request:    r,
2✔
1310
                User:       token,
2✔
1311
                ReasonCode: http.StatusOK,
2✔
1312
                Action:     cadf.UpdateAction,
2✔
1313
                Target: commitmentEventTarget{
2✔
1314
                        DomainID:    dbDomain.UUID,
2✔
1315
                        DomainName:  dbDomain.Name,
2✔
1316
                        ProjectID:   dbProject.UUID,
2✔
1317
                        ProjectName: dbProject.Name,
2✔
1318
                        Commitments: []limesresources.Commitment{c},
2✔
1319
                },
2✔
1320
        })
2✔
1321

2✔
1322
        respondwith.JSON(w, http.StatusOK, map[string]any{"commitment": c})
2✔
1323
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc