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

sapcc / limes / 14023639065

23 Mar 2025 10:30PM UTC coverage: 79.468% (+0.02%) from 79.445%
14023639065

Pull #674

github

majewsky
review: do not block renewal on a non-empty transfer status

There is currently no way for a user to reset the transfer status on a
commitment, so if a transfer is started and not completed, this would
block renewing the commitment, which is bad UX.

I understand that this restriction was meant to avoid a race condition
when a commitment is renewed at the same time as being transferred.
But both these operations touch the original commitment, so one of the
associated DB transactions would be forced to fail on commit. That's
good enough of a guarantee for me.
Pull Request #674: Add commitment renewal endpoint

109 of 135 new or added lines in 2 files covered. (80.74%)

139 existing lines in 3 files now uncovered.

6038 of 7598 relevant lines covered (79.47%)

61.56 hits per line

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

79.59
/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
        "maps"
27
        "net/http"
28
        "slices"
29
        "strings"
30
        "time"
31

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

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

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

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

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

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

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

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

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

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

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

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

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

223
func (p *v1Provider) parseAndValidateCommitmentRequest(w http.ResponseWriter, r *http.Request) (*limesresources.CommitmentRequest, *core.AZResourceLocation, *core.ResourceBehavior) {
46✔
224
        // parse request
46✔
225
        var parseTarget struct {
46✔
226
                Request limesresources.CommitmentRequest `json:"commitment"`
46✔
227
        }
46✔
228
        if !RequireJSON(w, r, &parseTarget) {
47✔
229
                return nil, nil, nil
1✔
230
        }
1✔
231
        req := parseTarget.Request
45✔
232

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

269
        loc := core.AZResourceLocation{
35✔
270
                ServiceType:      dbServiceType,
35✔
271
                ResourceName:     dbResourceName,
35✔
272
                AvailabilityZone: req.AvailabilityZone,
35✔
273
        }
35✔
274
        return &req, &loc, &behavior
35✔
275
}
276

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

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

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

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

329
// CreateProjectCommitment handles POST /v1/domains/:domain_id/projects/:project_id/commitments/new.
330
func (p *v1Provider) CreateProjectCommitment(w http.ResponseWriter, r *http.Request) {
42✔
331
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/new")
42✔
332
        token := p.CheckToken(r)
42✔
333
        if !token.Require(w, "project:edit") {
43✔
334
                return
1✔
335
        }
1✔
336
        dbDomain := p.FindDomainFromRequest(w, r)
41✔
337
        if dbDomain == nil {
42✔
338
                return
1✔
339
        }
1✔
340
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
40✔
341
        if dbProject == nil {
41✔
342
                return
1✔
343
        }
1✔
344
        req, loc, behavior := p.parseAndValidateCommitmentRequest(w, r)
39✔
345
        if req == nil {
50✔
346
                return
11✔
347
        }
11✔
348

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

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

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

25✔
386
        // prepare commitment
25✔
387
        confirmBy := maybeUnpackUnixEncodedTime(req.ConfirmBy)
25✔
388
        creationContext := db.CommitmentWorkflowContext{Reason: db.CommitmentReasonCreate}
25✔
389
        buf, err := json.Marshal(creationContext)
25✔
390
        if respondwith.ErrorText(w, err) {
25✔
391
                return
×
392
        }
×
393
        dbCommitment := db.ProjectCommitment{
25✔
394
                AZResourceID:        azResourceID,
25✔
395
                Amount:              req.Amount,
25✔
396
                Duration:            req.Duration,
25✔
397
                CreatedAt:           now,
25✔
398
                CreatorUUID:         token.UserUUID(),
25✔
399
                CreatorName:         fmt.Sprintf("%s@%s", token.UserName(), token.UserDomainName()),
25✔
400
                ConfirmBy:           confirmBy,
25✔
401
                ConfirmedAt:         nil, // may be set below
25✔
402
                ExpiresAt:           req.Duration.AddTo(unwrapOrDefault(confirmBy, now)),
25✔
403
                CreationContextJSON: json.RawMessage(buf),
25✔
404
        }
25✔
405
        if req.NotifyOnConfirm && req.ConfirmBy == nil {
26✔
406
                http.Error(w, "notification on confirm cannot be set for commitments with immediate confirmation", http.StatusConflict)
1✔
407
                return
1✔
408
        }
1✔
409
        dbCommitment.NotifyOnConfirm = req.NotifyOnConfirm
24✔
410

24✔
411
        if req.ConfirmBy == nil {
42✔
412
                // if not planned for confirmation in the future, confirm immediately (or fail)
18✔
413
                ok, err := datamodel.CanConfirmNewCommitment(*loc, resourceID, req.Amount, p.Cluster, tx)
18✔
414
                if respondwith.ErrorText(w, err) {
18✔
415
                        return
×
416
                }
×
417
                if !ok {
18✔
418
                        http.Error(w, "not enough capacity available for immediate confirmation", http.StatusConflict)
×
419
                        return
×
420
                }
×
421
                dbCommitment.ConfirmedAt = &now
18✔
422
                dbCommitment.State = db.CommitmentStateActive
18✔
423
        } else {
6✔
424
                dbCommitment.State = db.CommitmentStatePlanned
6✔
425
        }
6✔
426

427
        // create commitment
428
        err = tx.Insert(&dbCommitment)
24✔
429
        if respondwith.ErrorText(w, err) {
24✔
430
                return
×
431
        }
×
432
        err = tx.Commit()
24✔
433
        if respondwith.ErrorText(w, err) {
24✔
434
                return
×
435
        }
×
436
        p.auditor.Record(audittools.Event{
24✔
437
                Time:       now,
24✔
438
                Request:    r,
24✔
439
                User:       token,
24✔
440
                ReasonCode: http.StatusCreated,
24✔
441
                Action:     cadf.CreateAction,
24✔
442
                Target: commitmentEventTarget{
24✔
443
                        DomainID:        dbDomain.UUID,
24✔
444
                        DomainName:      dbDomain.Name,
24✔
445
                        ProjectID:       dbProject.UUID,
24✔
446
                        ProjectName:     dbProject.Name,
24✔
447
                        Commitments:     []limesresources.Commitment{p.convertCommitmentToDisplayForm(dbCommitment, *loc, token)},
24✔
448
                        WorkflowContext: &creationContext,
24✔
449
                },
24✔
450
        })
24✔
451

24✔
452
        // if the commitment is immediately confirmed, trigger a capacity scrape in
24✔
453
        // order to ApplyComputedProjectQuotas based on the new commitment
24✔
454
        if dbCommitment.ConfirmedAt != nil {
42✔
455
                _, err := p.DB.Exec(forceImmediateCapacityScrapeQuery, now, loc.ServiceType, loc.ResourceName)
18✔
456
                if respondwith.ErrorText(w, err) {
18✔
457
                        return
×
458
                }
×
459
        }
460

461
        // display the possibly confirmed commitment to the user
462
        err = p.DB.SelectOne(&dbCommitment, `SELECT * FROM project_commitments WHERE id = $1`, dbCommitment.ID)
24✔
463
        if respondwith.ErrorText(w, err) {
24✔
464
                return
×
465
        }
×
466

467
        c := p.convertCommitmentToDisplayForm(dbCommitment, *loc, token)
24✔
468
        respondwith.JSON(w, http.StatusCreated, map[string]any{"commitment": c})
24✔
469
}
470

471
// MergeProjectCommitments handles POST /v1/domains/:domain_id/projects/:project_id/commitments/merge.
472
func (p *v1Provider) MergeProjectCommitments(w http.ResponseWriter, r *http.Request) {
12✔
473
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/merge")
12✔
474
        token := p.CheckToken(r)
12✔
475
        if !token.Require(w, "project:edit") {
13✔
476
                return
1✔
477
        }
1✔
478
        dbDomain := p.FindDomainFromRequest(w, r)
11✔
479
        if dbDomain == nil {
12✔
480
                return
1✔
481
        }
1✔
482
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
10✔
483
        if dbProject == nil {
11✔
484
                return
1✔
485
        }
1✔
486
        var parseTarget struct {
9✔
487
                CommitmentIDs []db.ProjectCommitmentID `json:"commitment_ids"`
9✔
488
        }
9✔
489
        if !RequireJSON(w, r, &parseTarget) {
9✔
490
                return
×
491
        }
×
492
        commitmentIDs := parseTarget.CommitmentIDs
9✔
493
        if len(commitmentIDs) < 2 {
10✔
494
                http.Error(w, fmt.Sprintf("merging requires at least two commitments, but %d were given", len(commitmentIDs)), http.StatusBadRequest)
1✔
495
                return
1✔
496
        }
1✔
497

498
        // Load commitments
499
        dbCommitments := make([]db.ProjectCommitment, len(commitmentIDs))
8✔
500
        for i, commitmentID := range commitmentIDs {
24✔
501
                err := p.DB.SelectOne(&dbCommitments[i], findProjectCommitmentByIDQuery, commitmentID, dbProject.ID)
16✔
502
                if errors.Is(err, sql.ErrNoRows) {
17✔
503
                        http.Error(w, "no such commitment", http.StatusNotFound)
1✔
504
                        return
1✔
505
                } else if respondwith.ErrorText(w, err) {
16✔
506
                        return
×
507
                }
×
508
        }
509

510
        // Verify that all commitments agree on resource and AZ and are active
511
        azResourceID := dbCommitments[0].AZResourceID
7✔
512
        for _, dbCommitment := range dbCommitments {
21✔
513
                if dbCommitment.AZResourceID != azResourceID {
16✔
514
                        http.Error(w, "all commitments must be on the same resource and AZ", http.StatusConflict)
2✔
515
                        return
2✔
516
                }
2✔
517
                if dbCommitment.State != db.CommitmentStateActive {
16✔
518
                        http.Error(w, "only active commitments may be merged", http.StatusConflict)
4✔
519
                        return
4✔
520
                }
4✔
521
        }
522

523
        var loc core.AZResourceLocation
1✔
524
        err := p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, azResourceID).
1✔
525
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
1✔
526
        if errors.Is(err, sql.ErrNoRows) {
1✔
527
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
528
                return
×
529
        } else if respondwith.ErrorText(w, err) {
1✔
530
                return
×
531
        }
×
532

533
        // Start transaction for creating new commitment and marking merged commitments as superseded
534
        tx, err := p.DB.Begin()
1✔
535
        if respondwith.ErrorText(w, err) {
1✔
536
                return
×
537
        }
×
538
        defer sqlext.RollbackUnlessCommitted(tx)
1✔
539

1✔
540
        // Create merged template
1✔
541
        now := p.timeNow()
1✔
542
        dbMergedCommitment := db.ProjectCommitment{
1✔
543
                AZResourceID: azResourceID,
1✔
544
                Amount:       0,                                   // overwritten below
1✔
545
                Duration:     limesresources.CommitmentDuration{}, // overwritten below
1✔
546
                CreatedAt:    now,
1✔
547
                CreatorUUID:  token.UserUUID(),
1✔
548
                CreatorName:  fmt.Sprintf("%s@%s", token.UserName(), token.UserDomainName()),
1✔
549
                ConfirmedAt:  &now,
1✔
550
                ExpiresAt:    time.Time{}, // overwritten below
1✔
551
                State:        db.CommitmentStateActive,
1✔
552
        }
1✔
553

1✔
554
        // Fill amount and latest expiration date
1✔
555
        for _, dbCommitment := range dbCommitments {
3✔
556
                dbMergedCommitment.Amount += dbCommitment.Amount
2✔
557
                if dbCommitment.ExpiresAt.After(dbMergedCommitment.ExpiresAt) {
4✔
558
                        dbMergedCommitment.ExpiresAt = dbCommitment.ExpiresAt
2✔
559
                        dbMergedCommitment.Duration = dbCommitment.Duration
2✔
560
                }
2✔
561
        }
562

563
        // Fill workflow context
564
        creationContext := db.CommitmentWorkflowContext{
1✔
565
                Reason:               db.CommitmentReasonMerge,
1✔
566
                RelatedCommitmentIDs: commitmentIDs,
1✔
567
        }
1✔
568
        buf, err := json.Marshal(creationContext)
1✔
569
        if respondwith.ErrorText(w, err) {
1✔
570
                return
×
571
        }
×
572
        dbMergedCommitment.CreationContextJSON = json.RawMessage(buf)
1✔
573

1✔
574
        // Insert into database
1✔
575
        err = tx.Insert(&dbMergedCommitment)
1✔
576
        if respondwith.ErrorText(w, err) {
1✔
577
                return
×
578
        }
×
579

580
        // Mark merged commits as superseded
581
        supersedeContext := db.CommitmentWorkflowContext{
1✔
582
                Reason:               db.CommitmentReasonMerge,
1✔
583
                RelatedCommitmentIDs: []db.ProjectCommitmentID{dbMergedCommitment.ID},
1✔
584
        }
1✔
585
        buf, err = json.Marshal(supersedeContext)
1✔
586
        if respondwith.ErrorText(w, err) {
1✔
587
                return
×
588
        }
×
589
        for _, dbCommitment := range dbCommitments {
3✔
590
                dbCommitment.SupersededAt = &now
2✔
591
                dbCommitment.SupersedeContextJSON = liquids.PointerTo(json.RawMessage(buf))
2✔
592
                dbCommitment.State = db.CommitmentStateSuperseded
2✔
593
                _, err = tx.Update(&dbCommitment)
2✔
594
                if respondwith.ErrorText(w, err) {
2✔
595
                        return
×
596
                }
×
597
        }
598

599
        err = tx.Commit()
1✔
600
        if respondwith.ErrorText(w, err) {
1✔
601
                return
×
602
        }
×
603

604
        c := p.convertCommitmentToDisplayForm(dbMergedCommitment, loc, token)
1✔
605
        auditEvent := commitmentEventTarget{
1✔
606
                DomainID:        dbDomain.UUID,
1✔
607
                DomainName:      dbDomain.Name,
1✔
608
                ProjectID:       dbProject.UUID,
1✔
609
                ProjectName:     dbProject.Name,
1✔
610
                Commitments:     []limesresources.Commitment{c},
1✔
611
                WorkflowContext: &creationContext,
1✔
612
        }
1✔
613
        p.auditor.Record(audittools.Event{
1✔
614
                Time:       p.timeNow(),
1✔
615
                Request:    r,
1✔
616
                User:       token,
1✔
617
                ReasonCode: http.StatusAccepted,
1✔
618
                Action:     cadf.UpdateAction,
1✔
619
                Target:     auditEvent,
1✔
620
        })
1✔
621

1✔
622
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
1✔
623
}
624

625
// As per the API spec, commitments can be renewed 90 days in advance at the earliest.
626
const commitmentRenewalPeriod = 90 * 24 * time.Hour
627

628
// RenewProjectCommitments handles POST /v1/domains/:domain_id/projects/:project_id/commitments/renew.
629
func (p *v1Provider) RenewProjectCommitments(w http.ResponseWriter, r *http.Request) {
5✔
630
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/renew")
5✔
631
        token := p.CheckToken(r)
5✔
632
        if !token.Require(w, "project:edit") {
5✔
NEW
633
                return
×
NEW
634
        }
×
635
        dbDomain := p.FindDomainFromRequest(w, r)
5✔
636
        if dbDomain == nil {
5✔
NEW
637
                return
×
NEW
638
        }
×
639
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
5✔
640
        if dbProject == nil {
5✔
NEW
641
                return
×
NEW
642
        }
×
643
        var parseTarget struct {
5✔
644
                CommitmentIDs []db.ProjectCommitmentID `json:"commitment_ids"`
5✔
645
        }
5✔
646
        if !RequireJSON(w, r, &parseTarget) {
5✔
NEW
647
                return
×
NEW
648
        }
×
649

650
        // Load commitments
651
        commitmentIDs := parseTarget.CommitmentIDs
5✔
652
        dbCommitments := make([]db.ProjectCommitment, len(commitmentIDs))
5✔
653
        for i, commitmentID := range commitmentIDs {
12✔
654
                err := p.DB.SelectOne(&dbCommitments[i], findProjectCommitmentByIDQuery, commitmentID, dbProject.ID)
7✔
655
                if errors.Is(err, sql.ErrNoRows) {
7✔
NEW
656
                        http.Error(w, "no such commitment", http.StatusNotFound)
×
NEW
657
                        return
×
658
                } else if respondwith.ErrorText(w, err) {
7✔
NEW
659
                        return
×
NEW
660
                }
×
661
        }
662
        now := p.timeNow()
5✔
663

5✔
664
        // Check if commitments are renewable
5✔
665
        for _, dbCommitment := range dbCommitments {
11✔
666
                var errs errext.ErrorSet
6✔
667
                if dbCommitment.State != db.CommitmentStateActive {
7✔
668
                        errs.Addf("invalid state %q", dbCommitment.State)
1✔
669
                } else if now.After(dbCommitment.ExpiresAt) {
7✔
670
                        errs.Addf("invalid state %q", db.CommitmentStateExpired)
1✔
671
                }
1✔
672
                if now.Before(dbCommitment.ExpiresAt.Add(-commitmentRenewalPeriod)) {
7✔
673
                        errs.Addf("renewal attempt too early")
1✔
674
                }
1✔
675
                if dbCommitment.WasRenewed {
7✔
676
                        errs.Addf("already renewed")
1✔
677
                }
1✔
678

679
                if !errs.IsEmpty() {
10✔
680
                        msg := fmt.Sprintf("cannot renew commitment %d: %s", dbCommitment.ID, errs.Join(", "))
4✔
681
                        http.Error(w, msg, http.StatusConflict)
4✔
682
                        return
4✔
683
                }
4✔
684
        }
685

686
        // Create renewed commitments
687
        tx, err := p.DB.Begin()
1✔
688
        if respondwith.ErrorText(w, err) {
1✔
NEW
689
                return
×
NEW
690
        }
×
691
        defer sqlext.RollbackUnlessCommitted(tx)
1✔
692

1✔
693
        type renewContext struct {
1✔
694
                commitment db.ProjectCommitment
1✔
695
                location   core.AZResourceLocation
1✔
696
                context    db.CommitmentWorkflowContext
1✔
697
        }
1✔
698
        dbRenewedCommitments := make(map[db.ProjectCommitmentID]renewContext)
1✔
699
        for _, commitment := range dbCommitments {
3✔
700
                var loc core.AZResourceLocation
2✔
701
                err := p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, commitment.AZResourceID).
2✔
702
                        Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
2✔
703
                if errors.Is(err, sql.ErrNoRows) {
2✔
NEW
704
                        http.Error(w, "no route to this commitment", http.StatusNotFound)
×
NEW
705
                        return
×
706
                } else if respondwith.ErrorText(w, err) {
2✔
NEW
707
                        return
×
NEW
708
                }
×
709

710
                creationContext := db.CommitmentWorkflowContext{
2✔
711
                        Reason:               db.CommitmentReasonRenew,
2✔
712
                        RelatedCommitmentIDs: []db.ProjectCommitmentID{commitment.ID},
2✔
713
                }
2✔
714
                buf, err := json.Marshal(creationContext)
2✔
715
                if respondwith.ErrorText(w, err) {
2✔
NEW
716
                        return
×
NEW
717
                }
×
718
                dbRenewedCommitment := db.ProjectCommitment{
2✔
719
                        AZResourceID:        commitment.AZResourceID,
2✔
720
                        Amount:              commitment.Amount,
2✔
721
                        Duration:            commitment.Duration,
2✔
722
                        CreatedAt:           now,
2✔
723
                        CreatorUUID:         token.UserUUID(),
2✔
724
                        CreatorName:         fmt.Sprintf("%s@%s", token.UserName(), token.UserDomainName()),
2✔
725
                        ConfirmBy:           &commitment.ExpiresAt,
2✔
726
                        ExpiresAt:           commitment.Duration.AddTo(unwrapOrDefault(&commitment.ExpiresAt, now)),
2✔
727
                        State:               db.CommitmentStatePlanned,
2✔
728
                        CreationContextJSON: json.RawMessage(buf),
2✔
729
                }
2✔
730

2✔
731
                err = tx.Insert(&dbRenewedCommitment)
2✔
732
                if respondwith.ErrorText(w, err) {
2✔
NEW
733
                        return
×
NEW
734
                }
×
735
                dbRenewedCommitments[dbRenewedCommitment.ID] = renewContext{commitment: dbRenewedCommitment, location: loc, context: creationContext}
2✔
736

2✔
737
                commitment.WasRenewed = true
2✔
738
                _, err = tx.Update(&commitment)
2✔
739
                if respondwith.ErrorText(w, err) {
2✔
NEW
740
                        return
×
NEW
741
                }
×
742
        }
743

744
        err = tx.Commit()
1✔
745
        if respondwith.ErrorText(w, err) {
1✔
NEW
746
                return
×
NEW
747
        }
×
748

749
        // Create resultset and auditlogs
750
        var commitments []limesresources.Commitment
1✔
751
        for _, key := range slices.Sorted(maps.Keys(dbRenewedCommitments)) {
3✔
752
                ctx := dbRenewedCommitments[key]
2✔
753
                c := p.convertCommitmentToDisplayForm(ctx.commitment, ctx.location, token)
2✔
754
                commitments = append(commitments, c)
2✔
755
                auditEvent := commitmentEventTarget{
2✔
756
                        DomainID:        dbDomain.UUID,
2✔
757
                        DomainName:      dbDomain.Name,
2✔
758
                        ProjectID:       dbProject.UUID,
2✔
759
                        ProjectName:     dbProject.Name,
2✔
760
                        Commitments:     []limesresources.Commitment{c},
2✔
761
                        WorkflowContext: &ctx.context,
2✔
762
                }
2✔
763

2✔
764
                p.auditor.Record(audittools.Event{
2✔
765
                        Time:       p.timeNow(),
2✔
766
                        Request:    r,
2✔
767
                        User:       token,
2✔
768
                        ReasonCode: http.StatusAccepted,
2✔
769
                        Action:     cadf.UpdateAction,
2✔
770
                        Target:     auditEvent,
2✔
771
                })
2✔
772
        }
2✔
773

774
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitments": commitments})
1✔
775
}
776

777
// DeleteProjectCommitment handles DELETE /v1/domains/:domain_id/projects/:project_id/commitments/:id.
778
func (p *v1Provider) DeleteProjectCommitment(w http.ResponseWriter, r *http.Request) {
8✔
779
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/:id")
8✔
780
        token := p.CheckToken(r)
8✔
781
        if !token.Require(w, "project:edit") { //NOTE: There is a more specific AuthZ check further down below.
8✔
UNCOV
782
                return
×
783
        }
×
784
        dbDomain := p.FindDomainFromRequest(w, r)
8✔
785
        if dbDomain == nil {
9✔
786
                return
1✔
787
        }
1✔
788
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
7✔
789
        if dbProject == nil {
8✔
790
                return
1✔
791
        }
1✔
792

793
        // load commitment
794
        var dbCommitment db.ProjectCommitment
6✔
795
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, mux.Vars(r)["id"], dbProject.ID)
6✔
796
        if errors.Is(err, sql.ErrNoRows) {
7✔
797
                http.Error(w, "no such commitment", http.StatusNotFound)
1✔
798
                return
1✔
799
        } else if respondwith.ErrorText(w, err) {
6✔
UNCOV
800
                return
×
UNCOV
801
        }
×
802
        var loc core.AZResourceLocation
5✔
803
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
5✔
804
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
5✔
805
        if errors.Is(err, sql.ErrNoRows) {
5✔
806
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
UNCOV
807
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
UNCOV
808
                return
×
809
        } else if respondwith.ErrorText(w, err) {
5✔
UNCOV
810
                return
×
UNCOV
811
        }
×
812

813
        // check authorization for this specific commitment
814
        if !p.canDeleteCommitment(token, dbCommitment) {
6✔
815
                http.Error(w, "Forbidden", http.StatusForbidden)
1✔
816
                return
1✔
817
        }
1✔
818

819
        // perform deletion
820
        _, err = p.DB.Delete(&dbCommitment)
4✔
821
        if respondwith.ErrorText(w, err) {
4✔
UNCOV
822
                return
×
UNCOV
823
        }
×
824
        p.auditor.Record(audittools.Event{
4✔
825
                Time:       p.timeNow(),
4✔
826
                Request:    r,
4✔
827
                User:       token,
4✔
828
                ReasonCode: http.StatusNoContent,
4✔
829
                Action:     cadf.DeleteAction,
4✔
830
                Target: commitmentEventTarget{
4✔
831
                        DomainID:    dbDomain.UUID,
4✔
832
                        DomainName:  dbDomain.Name,
4✔
833
                        ProjectID:   dbProject.UUID,
4✔
834
                        ProjectName: dbProject.Name,
4✔
835
                        Commitments: []limesresources.Commitment{p.convertCommitmentToDisplayForm(dbCommitment, loc, token)},
4✔
836
                },
4✔
837
        })
4✔
838

4✔
839
        w.WriteHeader(http.StatusNoContent)
4✔
840
}
841

842
func (p *v1Provider) canDeleteCommitment(token *gopherpolicy.Token, commitment db.ProjectCommitment) bool {
88✔
843
        // up to 24 hours after creation of fresh commitments, future commitments can still be deleted by their creators
88✔
844
        if commitment.State == db.CommitmentStatePlanned || commitment.State == db.CommitmentStatePending || commitment.State == db.CommitmentStateActive {
176✔
845
                var creationContext db.CommitmentWorkflowContext
88✔
846
                err := json.Unmarshal(commitment.CreationContextJSON, &creationContext)
88✔
847
                if err == nil && creationContext.Reason == db.CommitmentReasonCreate && p.timeNow().Before(commitment.CreatedAt.Add(24*time.Hour)) {
152✔
848
                        if token.Check("project:edit") {
128✔
849
                                return true
64✔
850
                        }
64✔
851
                }
852
        }
853

854
        // afterwards, a more specific permission is required to delete it
855
        //
856
        // This protects cloud admins making capacity planning decisions based on future commitments
857
        // from having their forecasts ruined by project admins suffering from buyer's remorse.
858
        return token.Check("project:uncommit")
24✔
859
}
860

861
// StartCommitmentTransfer handles POST /v1/domains/:id/projects/:id/commitments/:id/start-transfer
862
func (p *v1Provider) StartCommitmentTransfer(w http.ResponseWriter, r *http.Request) {
8✔
863
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/:id/start-transfer")
8✔
864
        token := p.CheckToken(r)
8✔
865
        if !token.Require(w, "project:edit") {
8✔
UNCOV
866
                return
×
867
        }
×
868
        dbDomain := p.FindDomainFromRequest(w, r)
8✔
869
        if dbDomain == nil {
8✔
UNCOV
870
                return
×
UNCOV
871
        }
×
872
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
8✔
873
        if dbProject == nil {
8✔
874
                return
×
UNCOV
875
        }
×
876
        // TODO: eventually migrate this struct into go-api-declarations
877
        var parseTarget struct {
8✔
878
                Request struct {
8✔
879
                        Amount         uint64                                  `json:"amount"`
8✔
880
                        TransferStatus limesresources.CommitmentTransferStatus `json:"transfer_status,omitempty"`
8✔
881
                } `json:"commitment"`
8✔
882
        }
8✔
883
        if !RequireJSON(w, r, &parseTarget) {
8✔
UNCOV
884
                return
×
885
        }
×
886
        req := parseTarget.Request
8✔
887

8✔
888
        if req.TransferStatus != limesresources.CommitmentTransferStatusUnlisted && req.TransferStatus != limesresources.CommitmentTransferStatusPublic {
8✔
889
                http.Error(w, fmt.Sprintf("Invalid transfer_status code. Must be %s or %s.", limesresources.CommitmentTransferStatusUnlisted, limesresources.CommitmentTransferStatusPublic), http.StatusBadRequest)
×
UNCOV
890
                return
×
UNCOV
891
        }
×
892

893
        if req.Amount <= 0 {
9✔
894
                http.Error(w, "delivered amount needs to be a positive value.", http.StatusBadRequest)
1✔
895
                return
1✔
896
        }
1✔
897

898
        // load commitment
899
        var dbCommitment db.ProjectCommitment
7✔
900
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, mux.Vars(r)["id"], dbProject.ID)
7✔
901
        if errors.Is(err, sql.ErrNoRows) {
7✔
UNCOV
902
                http.Error(w, "no such commitment", http.StatusNotFound)
×
UNCOV
903
                return
×
904
        } else if respondwith.ErrorText(w, err) {
7✔
UNCOV
905
                return
×
UNCOV
906
        }
×
907

908
        // Mark whole commitment or a newly created, splitted one as transferrable.
909
        tx, err := p.DB.Begin()
7✔
910
        if respondwith.ErrorText(w, err) {
7✔
911
                return
×
UNCOV
912
        }
×
913
        defer sqlext.RollbackUnlessCommitted(tx)
7✔
914
        transferToken := p.generateTransferToken()
7✔
915

7✔
916
        // Deny requests with a greater amount than the commitment.
7✔
917
        if req.Amount > dbCommitment.Amount {
8✔
918
                http.Error(w, "delivered amount exceeds the commitment amount.", http.StatusBadRequest)
1✔
919
                return
1✔
920
        }
1✔
921

922
        if req.Amount == dbCommitment.Amount {
10✔
923
                dbCommitment.TransferStatus = req.TransferStatus
4✔
924
                dbCommitment.TransferToken = &transferToken
4✔
925
                _, err = tx.Update(&dbCommitment)
4✔
926
                if respondwith.ErrorText(w, err) {
4✔
UNCOV
927
                        return
×
928
                }
×
929
        } else {
2✔
930
                now := p.timeNow()
2✔
931
                transferAmount := req.Amount
2✔
932
                remainingAmount := dbCommitment.Amount - req.Amount
2✔
933
                transferCommitment, err := p.buildSplitCommitment(dbCommitment, transferAmount)
2✔
934
                if respondwith.ErrorText(w, err) {
2✔
UNCOV
935
                        return
×
UNCOV
936
                }
×
937
                transferCommitment.TransferStatus = req.TransferStatus
2✔
938
                transferCommitment.TransferToken = &transferToken
2✔
939
                remainingCommitment, err := p.buildSplitCommitment(dbCommitment, remainingAmount)
2✔
940
                if respondwith.ErrorText(w, err) {
2✔
941
                        return
×
UNCOV
942
                }
×
943
                err = tx.Insert(&transferCommitment)
2✔
944
                if respondwith.ErrorText(w, err) {
2✔
UNCOV
945
                        return
×
UNCOV
946
                }
×
947
                err = tx.Insert(&remainingCommitment)
2✔
948
                if respondwith.ErrorText(w, err) {
2✔
UNCOV
949
                        return
×
UNCOV
950
                }
×
951
                supersedeContext := db.CommitmentWorkflowContext{
2✔
952
                        Reason:               db.CommitmentReasonSplit,
2✔
953
                        RelatedCommitmentIDs: []db.ProjectCommitmentID{transferCommitment.ID, remainingCommitment.ID},
2✔
954
                }
2✔
955
                buf, err := json.Marshal(supersedeContext)
2✔
956
                if respondwith.ErrorText(w, err) {
2✔
UNCOV
957
                        return
×
UNCOV
958
                }
×
959
                dbCommitment.State = db.CommitmentStateSuperseded
2✔
960
                dbCommitment.SupersededAt = &now
2✔
961
                dbCommitment.SupersedeContextJSON = liquids.PointerTo(json.RawMessage(buf))
2✔
962
                _, err = tx.Update(&dbCommitment)
2✔
963
                if respondwith.ErrorText(w, err) {
2✔
964
                        return
×
965
                }
×
966
                dbCommitment = transferCommitment
2✔
967
        }
968
        err = tx.Commit()
6✔
969
        if respondwith.ErrorText(w, err) {
6✔
UNCOV
970
                return
×
UNCOV
971
        }
×
972

973
        var loc core.AZResourceLocation
6✔
974
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
6✔
975
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
6✔
976
        if errors.Is(err, sql.ErrNoRows) {
6✔
UNCOV
977
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
UNCOV
978
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
UNCOV
979
                return
×
980
        } else if respondwith.ErrorText(w, err) {
6✔
UNCOV
981
                return
×
UNCOV
982
        }
×
983

984
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token)
6✔
985
        p.auditor.Record(audittools.Event{
6✔
986
                Time:       p.timeNow(),
6✔
987
                Request:    r,
6✔
988
                User:       token,
6✔
989
                ReasonCode: http.StatusAccepted,
6✔
990
                Action:     cadf.UpdateAction,
6✔
991
                Target: commitmentEventTarget{
6✔
992
                        DomainID:    dbDomain.UUID,
6✔
993
                        DomainName:  dbDomain.Name,
6✔
994
                        ProjectID:   dbProject.UUID,
6✔
995
                        ProjectName: dbProject.Name,
6✔
996
                        Commitments: []limesresources.Commitment{c},
6✔
997
                },
6✔
998
        })
6✔
999
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
6✔
1000
}
1001

1002
func (p *v1Provider) buildSplitCommitment(dbCommitment db.ProjectCommitment, amount uint64) (db.ProjectCommitment, error) {
5✔
1003
        now := p.timeNow()
5✔
1004
        creationContext := db.CommitmentWorkflowContext{
5✔
1005
                Reason:               db.CommitmentReasonSplit,
5✔
1006
                RelatedCommitmentIDs: []db.ProjectCommitmentID{dbCommitment.ID},
5✔
1007
        }
5✔
1008
        buf, err := json.Marshal(creationContext)
5✔
1009
        if err != nil {
5✔
UNCOV
1010
                return db.ProjectCommitment{}, err
×
UNCOV
1011
        }
×
1012
        return db.ProjectCommitment{
5✔
1013
                AZResourceID:        dbCommitment.AZResourceID,
5✔
1014
                Amount:              amount,
5✔
1015
                Duration:            dbCommitment.Duration,
5✔
1016
                CreatedAt:           now,
5✔
1017
                CreatorUUID:         dbCommitment.CreatorUUID,
5✔
1018
                CreatorName:         dbCommitment.CreatorName,
5✔
1019
                ConfirmBy:           dbCommitment.ConfirmBy,
5✔
1020
                ConfirmedAt:         dbCommitment.ConfirmedAt,
5✔
1021
                ExpiresAt:           dbCommitment.ExpiresAt,
5✔
1022
                CreationContextJSON: json.RawMessage(buf),
5✔
1023
                State:               dbCommitment.State,
5✔
1024
        }, nil
5✔
1025
}
1026

1027
func (p *v1Provider) buildConvertedCommitment(dbCommitment db.ProjectCommitment, azResourceID db.ProjectAZResourceID, amount uint64) (db.ProjectCommitment, error) {
2✔
1028
        now := p.timeNow()
2✔
1029
        creationContext := db.CommitmentWorkflowContext{
2✔
1030
                Reason:               db.CommitmentReasonConvert,
2✔
1031
                RelatedCommitmentIDs: []db.ProjectCommitmentID{dbCommitment.ID},
2✔
1032
        }
2✔
1033
        buf, err := json.Marshal(creationContext)
2✔
1034
        if err != nil {
2✔
UNCOV
1035
                return db.ProjectCommitment{}, err
×
UNCOV
1036
        }
×
1037
        return db.ProjectCommitment{
2✔
1038
                AZResourceID:        azResourceID,
2✔
1039
                Amount:              amount,
2✔
1040
                Duration:            dbCommitment.Duration,
2✔
1041
                CreatedAt:           now,
2✔
1042
                CreatorUUID:         dbCommitment.CreatorUUID,
2✔
1043
                CreatorName:         dbCommitment.CreatorName,
2✔
1044
                ConfirmBy:           dbCommitment.ConfirmBy,
2✔
1045
                ConfirmedAt:         dbCommitment.ConfirmedAt,
2✔
1046
                ExpiresAt:           dbCommitment.ExpiresAt,
2✔
1047
                CreationContextJSON: json.RawMessage(buf),
2✔
1048
                State:               dbCommitment.State,
2✔
1049
        }, nil
2✔
1050
}
1051

1052
// GetCommitmentByTransferToken handles GET /v1/commitments/{token}
1053
func (p *v1Provider) GetCommitmentByTransferToken(w http.ResponseWriter, r *http.Request) {
2✔
1054
        httpapi.IdentifyEndpoint(r, "/v1/commitments/:token")
2✔
1055
        token := p.CheckToken(r)
2✔
1056
        if !token.Require(w, "cluster:show_basic") {
2✔
UNCOV
1057
                return
×
1058
        }
×
1059
        transferToken := mux.Vars(r)["token"]
2✔
1060

2✔
1061
        // The token column is a unique key, so we expect only one result.
2✔
1062
        var dbCommitment db.ProjectCommitment
2✔
1063
        err := p.DB.SelectOne(&dbCommitment, findCommitmentByTransferToken, transferToken)
2✔
1064
        if errors.Is(err, sql.ErrNoRows) {
3✔
1065
                http.Error(w, "no matching commitment found.", http.StatusNotFound)
1✔
1066
                return
1✔
1067
        } else if respondwith.ErrorText(w, err) {
2✔
UNCOV
1068
                return
×
UNCOV
1069
        }
×
1070

1071
        var loc core.AZResourceLocation
1✔
1072
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
1✔
1073
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
1✔
1074
        if errors.Is(err, sql.ErrNoRows) {
1✔
1075
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
UNCOV
1076
                http.Error(w, "location data not found.", http.StatusNotFound)
×
UNCOV
1077
                return
×
1078
        } else if respondwith.ErrorText(w, err) {
1✔
UNCOV
1079
                return
×
UNCOV
1080
        }
×
1081

1082
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token)
1✔
1083
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
1✔
1084
}
1085

1086
// TransferCommitment handles POST /v1/domains/{domain_id}/projects/{project_id}/transfer-commitment/{id}?token={token}
1087
func (p *v1Provider) TransferCommitment(w http.ResponseWriter, r *http.Request) {
5✔
1088
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/transfer-commitment/:id")
5✔
1089
        token := p.CheckToken(r)
5✔
1090
        if !token.Require(w, "project:edit") {
5✔
UNCOV
1091
                return
×
1092
        }
×
1093
        transferToken := r.Header.Get("Transfer-Token")
5✔
1094
        if transferToken == "" {
6✔
1095
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
1✔
1096
                return
1✔
1097
        }
1✔
1098
        commitmentID := mux.Vars(r)["id"]
4✔
1099
        if commitmentID == "" {
4✔
UNCOV
1100
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
×
UNCOV
1101
                return
×
1102
        }
×
1103
        dbDomain := p.FindDomainFromRequest(w, r)
4✔
1104
        if dbDomain == nil {
4✔
UNCOV
1105
                return
×
UNCOV
1106
        }
×
1107
        targetProject := p.FindProjectFromRequest(w, r, dbDomain)
4✔
1108
        if targetProject == nil {
4✔
1109
                return
×
1110
        }
×
1111

1112
        // find commitment by transfer_token
1113
        var dbCommitment db.ProjectCommitment
4✔
1114
        err := p.DB.SelectOne(&dbCommitment, getCommitmentWithMatchingTransferTokenQuery, commitmentID, transferToken)
4✔
1115
        if errors.Is(err, sql.ErrNoRows) {
5✔
1116
                http.Error(w, "no matching commitment found", http.StatusNotFound)
1✔
1117
                return
1✔
1118
        } else if respondwith.ErrorText(w, err) {
4✔
UNCOV
1119
                return
×
UNCOV
1120
        }
×
1121

1122
        var loc core.AZResourceLocation
3✔
1123
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
3✔
1124
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
3✔
1125
        if errors.Is(err, sql.ErrNoRows) {
3✔
1126
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
UNCOV
1127
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
UNCOV
1128
                return
×
1129
        } else if respondwith.ErrorText(w, err) {
3✔
UNCOV
1130
                return
×
1131
        }
×
1132

1133
        // get target service and AZ resource
1134
        var (
3✔
1135
                sourceResourceID   db.ProjectResourceID
3✔
1136
                targetResourceID   db.ProjectResourceID
3✔
1137
                targetAZResourceID db.ProjectAZResourceID
3✔
1138
        )
3✔
1139
        err = p.DB.QueryRow(findTargetAZResourceIDBySourceIDQuery, dbCommitment.AZResourceID, targetProject.ID).
3✔
1140
                Scan(&sourceResourceID, &targetResourceID, &targetAZResourceID)
3✔
1141
        if respondwith.ErrorText(w, err) {
3✔
UNCOV
1142
                return
×
UNCOV
1143
        }
×
1144

1145
        // validate that we have enough committable capacity on the receiving side
1146
        tx, err := p.DB.Begin()
3✔
1147
        if respondwith.ErrorText(w, err) {
3✔
1148
                return
×
1149
        }
×
1150
        defer sqlext.RollbackUnlessCommitted(tx)
3✔
1151
        ok, err := datamodel.CanMoveExistingCommitment(dbCommitment.Amount, loc, sourceResourceID, targetResourceID, p.Cluster, tx)
3✔
1152
        if respondwith.ErrorText(w, err) {
3✔
1153
                return
×
UNCOV
1154
        }
×
1155
        if !ok {
4✔
1156
                http.Error(w, "not enough committable capacity on the receiving side", http.StatusConflict)
1✔
1157
                return
1✔
1158
        }
1✔
1159

1160
        dbCommitment.TransferStatus = ""
2✔
1161
        dbCommitment.TransferToken = nil
2✔
1162
        dbCommitment.AZResourceID = targetAZResourceID
2✔
1163
        _, err = tx.Update(&dbCommitment)
2✔
1164
        if respondwith.ErrorText(w, err) {
2✔
UNCOV
1165
                return
×
UNCOV
1166
        }
×
1167
        err = tx.Commit()
2✔
1168
        if respondwith.ErrorText(w, err) {
2✔
UNCOV
1169
                return
×
UNCOV
1170
        }
×
1171

1172
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token)
2✔
1173
        p.auditor.Record(audittools.Event{
2✔
1174
                Time:       p.timeNow(),
2✔
1175
                Request:    r,
2✔
1176
                User:       token,
2✔
1177
                ReasonCode: http.StatusAccepted,
2✔
1178
                Action:     cadf.UpdateAction,
2✔
1179
                Target: commitmentEventTarget{
2✔
1180
                        DomainID:    dbDomain.UUID,
2✔
1181
                        DomainName:  dbDomain.Name,
2✔
1182
                        ProjectID:   targetProject.UUID,
2✔
1183
                        ProjectName: targetProject.Name,
2✔
1184
                        Commitments: []limesresources.Commitment{c},
2✔
1185
                },
2✔
1186
        })
2✔
1187

2✔
1188
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
2✔
1189
}
1190

1191
// GetCommitmentConversion handles GET /v1/commitment-conversion/{service_type}/{resource_name}
1192
func (p *v1Provider) GetCommitmentConversions(w http.ResponseWriter, r *http.Request) {
2✔
1193
        httpapi.IdentifyEndpoint(r, "/v1/commitment-conversion/:service_type/:resource_name")
2✔
1194
        token := p.CheckToken(r)
2✔
1195
        if !token.Require(w, "cluster:show_basic") {
2✔
UNCOV
1196
                return
×
UNCOV
1197
        }
×
1198

1199
        // validate request
1200
        vars := mux.Vars(r)
2✔
1201
        nm := core.BuildResourceNameMapping(p.Cluster)
2✔
1202
        sourceServiceType, sourceResourceName, exists := nm.MapFromV1API(
2✔
1203
                limes.ServiceType(vars["service_type"]),
2✔
1204
                limesresources.ResourceName(vars["resource_name"]),
2✔
1205
        )
2✔
1206
        if !exists {
2✔
UNCOV
1207
                msg := fmt.Sprintf("no such service and/or resource: %s/%s", vars["service_type"], vars["resource_name"])
×
UNCOV
1208
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
UNCOV
1209
                return
×
UNCOV
1210
        }
×
1211
        sourceBehavior := p.Cluster.BehaviorForResource(sourceServiceType, sourceResourceName)
2✔
1212
        sourceResInfo := p.Cluster.InfoForResource(sourceServiceType, sourceResourceName)
2✔
1213

2✔
1214
        // enumerate possible conversions
2✔
1215
        conversions := make([]limesresources.CommitmentConversionRule, 0)
2✔
1216
        for targetServiceType, quotaPlugin := range p.Cluster.QuotaPlugins {
8✔
1217
                for targetResourceName, targetResInfo := range quotaPlugin.Resources() {
28✔
1218
                        targetBehavior := p.Cluster.BehaviorForResource(targetServiceType, targetResourceName)
22✔
1219
                        if targetBehavior.CommitmentConversion == (core.CommitmentConversion{}) {
30✔
1220
                                continue
8✔
1221
                        }
1222
                        if sourceServiceType == targetServiceType && sourceResourceName == targetResourceName {
16✔
1223
                                continue
2✔
1224
                        }
1225
                        if sourceResInfo.Unit != targetResInfo.Unit {
19✔
1226
                                continue
7✔
1227
                        }
1228
                        if sourceBehavior.CommitmentConversion.Identifier != targetBehavior.CommitmentConversion.Identifier {
6✔
1229
                                continue
1✔
1230
                        }
1231

1232
                        fromAmount, toAmount := p.getCommitmentConversionRate(sourceBehavior, targetBehavior)
4✔
1233
                        apiServiceType, apiResourceName, ok := nm.MapToV1API(targetServiceType, targetResourceName)
4✔
1234
                        if ok {
8✔
1235
                                conversions = append(conversions, limesresources.CommitmentConversionRule{
4✔
1236
                                        FromAmount:     fromAmount,
4✔
1237
                                        ToAmount:       toAmount,
4✔
1238
                                        TargetService:  apiServiceType,
4✔
1239
                                        TargetResource: apiResourceName,
4✔
1240
                                })
4✔
1241
                        }
4✔
1242
                }
1243
        }
1244

1245
        // use a defined sorting to ensure deterministic behavior in tests
1246
        slices.SortFunc(conversions, func(lhs, rhs limesresources.CommitmentConversionRule) int {
5✔
1247
                result := strings.Compare(string(lhs.TargetService), string(rhs.TargetService))
3✔
1248
                if result != 0 {
5✔
1249
                        return result
2✔
1250
                }
2✔
1251
                return strings.Compare(string(lhs.TargetResource), string(rhs.TargetResource))
1✔
1252
        })
1253

1254
        respondwith.JSON(w, http.StatusOK, map[string]any{"conversions": conversions})
2✔
1255
}
1256

1257
// ConvertCommitment handles POST /v1/domains/{domain_id}/projects/{project_id}/commitments/{commitment_id}/convert
1258
func (p *v1Provider) ConvertCommitment(w http.ResponseWriter, r *http.Request) {
9✔
1259
        httpapi.IdentifyEndpoint(r, "/v1/domains/:domain_id/projects/:project_id/commitments/:commitment_id/convert")
9✔
1260
        token := p.CheckToken(r)
9✔
1261
        if !token.Require(w, "project:edit") {
9✔
UNCOV
1262
                return
×
UNCOV
1263
        }
×
1264
        commitmentID := mux.Vars(r)["commitment_id"]
9✔
1265
        if commitmentID == "" {
9✔
UNCOV
1266
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
×
UNCOV
1267
                return
×
1268
        }
×
1269
        dbDomain := p.FindDomainFromRequest(w, r)
9✔
1270
        if dbDomain == nil {
9✔
UNCOV
1271
                return
×
UNCOV
1272
        }
×
1273
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
9✔
1274
        if dbProject == nil {
9✔
1275
                return
×
1276
        }
×
1277

1278
        // section: sourceBehavior
1279
        var dbCommitment db.ProjectCommitment
9✔
1280
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, commitmentID, dbProject.ID)
9✔
1281
        if errors.Is(err, sql.ErrNoRows) {
10✔
1282
                http.Error(w, "no such commitment", http.StatusNotFound)
1✔
1283
                return
1✔
1284
        } else if respondwith.ErrorText(w, err) {
9✔
UNCOV
1285
                return
×
UNCOV
1286
        }
×
1287
        var sourceLoc core.AZResourceLocation
8✔
1288
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
8✔
1289
                Scan(&sourceLoc.ServiceType, &sourceLoc.ResourceName, &sourceLoc.AvailabilityZone)
8✔
1290
        if errors.Is(err, sql.ErrNoRows) {
8✔
UNCOV
1291
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
1292
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
1293
                return
×
1294
        } else if respondwith.ErrorText(w, err) {
8✔
UNCOV
1295
                return
×
UNCOV
1296
        }
×
1297
        sourceBehavior := p.Cluster.BehaviorForResource(sourceLoc.ServiceType, sourceLoc.ResourceName)
8✔
1298

8✔
1299
        // section: targetBehavior
8✔
1300
        var parseTarget struct {
8✔
1301
                Request struct {
8✔
1302
                        TargetService  limes.ServiceType           `json:"target_service"`
8✔
1303
                        TargetResource limesresources.ResourceName `json:"target_resource"`
8✔
1304
                        SourceAmount   uint64                      `json:"source_amount"`
8✔
1305
                        TargetAmount   uint64                      `json:"target_amount"`
8✔
1306
                } `json:"commitment"`
8✔
1307
        }
8✔
1308
        if !RequireJSON(w, r, &parseTarget) {
8✔
1309
                return
×
1310
        }
×
1311
        req := parseTarget.Request
8✔
1312
        nm := core.BuildResourceNameMapping(p.Cluster)
8✔
1313
        targetServiceType, targetResourceName, exists := nm.MapFromV1API(req.TargetService, req.TargetResource)
8✔
1314
        if !exists {
8✔
UNCOV
1315
                msg := fmt.Sprintf("no such service and/or resource: %s/%s", req.TargetService, req.TargetResource)
×
UNCOV
1316
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
UNCOV
1317
                return
×
UNCOV
1318
        }
×
1319
        targetBehavior := p.Cluster.BehaviorForResource(targetServiceType, targetResourceName)
8✔
1320
        if sourceLoc.ResourceName == targetResourceName && sourceLoc.ServiceType == targetServiceType {
9✔
1321
                http.Error(w, "conversion attempt to the same resource.", http.StatusConflict)
1✔
1322
                return
1✔
1323
        }
1✔
1324
        if len(targetBehavior.CommitmentDurations) == 0 {
7✔
UNCOV
1325
                msg := fmt.Sprintf("commitments are not enabled for resource %s/%s", req.TargetService, req.TargetResource)
×
UNCOV
1326
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
UNCOV
1327
                return
×
UNCOV
1328
        }
×
1329
        if sourceBehavior.CommitmentConversion.Identifier == "" || sourceBehavior.CommitmentConversion.Identifier != targetBehavior.CommitmentConversion.Identifier {
8✔
1330
                msg := fmt.Sprintf("commitment is not convertible into resource %s/%s", req.TargetService, req.TargetResource)
1✔
1331
                http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
1332
                return
1✔
1333
        }
1✔
1334

1335
        // section: conversion
1336
        if req.SourceAmount > dbCommitment.Amount {
6✔
UNCOV
1337
                msg := fmt.Sprintf("unprocessable source amount. provided: %v, commitment: %v", req.SourceAmount, dbCommitment.Amount)
×
UNCOV
1338
                http.Error(w, msg, http.StatusConflict)
×
UNCOV
1339
                return
×
1340
        }
×
1341
        fromAmount, toAmount := p.getCommitmentConversionRate(sourceBehavior, targetBehavior)
6✔
1342
        conversionAmount := (req.SourceAmount / fromAmount) * toAmount
6✔
1343
        remainderAmount := req.SourceAmount % fromAmount
6✔
1344
        if remainderAmount > 0 {
8✔
1345
                msg := fmt.Sprintf("amount: %v does not fit into conversion rate of: %v", req.SourceAmount, fromAmount)
2✔
1346
                http.Error(w, msg, http.StatusConflict)
2✔
1347
                return
2✔
1348
        }
2✔
1349
        if conversionAmount != req.TargetAmount {
5✔
1350
                msg := fmt.Sprintf("conversion mismatch. provided: %v, calculated: %v", req.TargetAmount, conversionAmount)
1✔
1351
                http.Error(w, msg, http.StatusConflict)
1✔
1352
                return
1✔
1353
        }
1✔
1354

1355
        tx, err := p.DB.Begin()
3✔
1356
        if respondwith.ErrorText(w, err) {
3✔
1357
                return
×
UNCOV
1358
        }
×
1359
        defer sqlext.RollbackUnlessCommitted(tx)
3✔
1360

3✔
1361
        var (
3✔
1362
                targetResourceID   db.ProjectResourceID
3✔
1363
                targetAZResourceID db.ProjectAZResourceID
3✔
1364
        )
3✔
1365
        err = p.DB.QueryRow(findTargetAZResourceByTargetProjectQuery, dbProject.ID, targetServiceType, targetResourceName, sourceLoc.AvailabilityZone).
3✔
1366
                Scan(&targetResourceID, &targetAZResourceID)
3✔
1367
        if respondwith.ErrorText(w, err) {
3✔
1368
                return
×
1369
        }
×
1370
        // defense in depth. ServiceType and ResourceName of source and target are already checked. Here it's possible to explicitly check the ID's.
1371
        if dbCommitment.AZResourceID == targetAZResourceID {
3✔
UNCOV
1372
                http.Error(w, "conversion attempt to the same resource.", http.StatusConflict)
×
UNCOV
1373
                return
×
UNCOV
1374
        }
×
1375
        targetLoc := core.AZResourceLocation{
3✔
1376
                ServiceType:      targetServiceType,
3✔
1377
                ResourceName:     targetResourceName,
3✔
1378
                AvailabilityZone: sourceLoc.AvailabilityZone,
3✔
1379
        }
3✔
1380
        // The commitment at the source resource was already confirmed and checked.
3✔
1381
        // Therefore only the addition to the target resource has to be checked against.
3✔
1382
        if dbCommitment.ConfirmedAt != nil {
5✔
1383
                ok, err := datamodel.CanConfirmNewCommitment(targetLoc, targetResourceID, conversionAmount, p.Cluster, p.DB)
2✔
1384
                if respondwith.ErrorText(w, err) {
2✔
UNCOV
1385
                        return
×
UNCOV
1386
                }
×
1387
                if !ok {
3✔
1388
                        http.Error(w, "not enough capacity to confirm the commitment", http.StatusUnprocessableEntity)
1✔
1389
                        return
1✔
1390
                }
1✔
1391
        }
1392

1393
        auditEvent := commitmentEventTarget{
2✔
1394
                DomainID:    dbDomain.UUID,
2✔
1395
                DomainName:  dbDomain.Name,
2✔
1396
                ProjectID:   dbProject.UUID,
2✔
1397
                ProjectName: dbProject.Name,
2✔
1398
        }
2✔
1399

2✔
1400
        relatedCommitmentIDs := make([]db.ProjectCommitmentID, 0)
2✔
1401
        remainingAmount := dbCommitment.Amount - req.SourceAmount
2✔
1402
        if remainingAmount > 0 {
3✔
1403
                remainingCommitment, err := p.buildSplitCommitment(dbCommitment, remainingAmount)
1✔
1404
                if respondwith.ErrorText(w, err) {
1✔
UNCOV
1405
                        return
×
UNCOV
1406
                }
×
1407
                relatedCommitmentIDs = append(relatedCommitmentIDs, remainingCommitment.ID)
1✔
1408
                err = tx.Insert(&remainingCommitment)
1✔
1409
                if respondwith.ErrorText(w, err) {
1✔
UNCOV
1410
                        return
×
UNCOV
1411
                }
×
1412
                auditEvent.Commitments = append(auditEvent.Commitments,
1✔
1413
                        p.convertCommitmentToDisplayForm(remainingCommitment, sourceLoc, token),
1✔
1414
                )
1✔
1415
        }
1416

1417
        convertedCommitment, err := p.buildConvertedCommitment(dbCommitment, targetAZResourceID, conversionAmount)
2✔
1418
        if respondwith.ErrorText(w, err) {
2✔
1419
                return
×
UNCOV
1420
        }
×
1421
        relatedCommitmentIDs = append(relatedCommitmentIDs, convertedCommitment.ID)
2✔
1422
        err = tx.Insert(&convertedCommitment)
2✔
1423
        if respondwith.ErrorText(w, err) {
2✔
UNCOV
1424
                return
×
1425
        }
×
1426

1427
        // supersede the original commitment
1428
        now := p.timeNow()
2✔
1429
        supersedeContext := db.CommitmentWorkflowContext{
2✔
1430
                Reason:               db.CommitmentReasonConvert,
2✔
1431
                RelatedCommitmentIDs: relatedCommitmentIDs,
2✔
1432
        }
2✔
1433
        buf, err := json.Marshal(supersedeContext)
2✔
1434
        if respondwith.ErrorText(w, err) {
2✔
UNCOV
1435
                return
×
UNCOV
1436
        }
×
1437
        dbCommitment.State = db.CommitmentStateSuperseded
2✔
1438
        dbCommitment.SupersededAt = &now
2✔
1439
        dbCommitment.SupersedeContextJSON = liquids.PointerTo(json.RawMessage(buf))
2✔
1440
        _, err = tx.Update(&dbCommitment)
2✔
1441
        if respondwith.ErrorText(w, err) {
2✔
UNCOV
1442
                return
×
UNCOV
1443
        }
×
1444

1445
        err = tx.Commit()
2✔
1446
        if respondwith.ErrorText(w, err) {
2✔
UNCOV
1447
                return
×
UNCOV
1448
        }
×
1449

1450
        c := p.convertCommitmentToDisplayForm(convertedCommitment, targetLoc, token)
2✔
1451
        auditEvent.Commitments = append([]limesresources.Commitment{c}, auditEvent.Commitments...)
2✔
1452
        auditEvent.WorkflowContext = &db.CommitmentWorkflowContext{
2✔
1453
                Reason:               db.CommitmentReasonSplit,
2✔
1454
                RelatedCommitmentIDs: []db.ProjectCommitmentID{dbCommitment.ID},
2✔
1455
        }
2✔
1456
        p.auditor.Record(audittools.Event{
2✔
1457
                Time:       p.timeNow(),
2✔
1458
                Request:    r,
2✔
1459
                User:       token,
2✔
1460
                ReasonCode: http.StatusAccepted,
2✔
1461
                Action:     cadf.UpdateAction,
2✔
1462
                Target:     auditEvent,
2✔
1463
        })
2✔
1464

2✔
1465
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
2✔
1466
}
1467

1468
func (p *v1Provider) getCommitmentConversionRate(source, target core.ResourceBehavior) (fromAmount, toAmount uint64) {
10✔
1469
        divisor := GetGreatestCommonDivisor(source.CommitmentConversion.Weight, target.CommitmentConversion.Weight)
10✔
1470
        fromAmount = target.CommitmentConversion.Weight / divisor
10✔
1471
        toAmount = source.CommitmentConversion.Weight / divisor
10✔
1472
        return fromAmount, toAmount
10✔
1473
}
10✔
1474

1475
// ExtendCommitmentDuration handles POST /v1/domains/{domain_id}/projects/{project_id}/commitments/{commitment_id}/update-duration
1476
func (p *v1Provider) UpdateCommitmentDuration(w http.ResponseWriter, r *http.Request) {
6✔
1477
        httpapi.IdentifyEndpoint(r, "/v1/domains/:domain_id/projects/:project_id/commitments/:commitment_id/update-duration")
6✔
1478
        token := p.CheckToken(r)
6✔
1479
        if !token.Require(w, "project:edit") {
6✔
UNCOV
1480
                return
×
UNCOV
1481
        }
×
1482
        commitmentID := mux.Vars(r)["commitment_id"]
6✔
1483
        if commitmentID == "" {
6✔
1484
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
×
UNCOV
1485
                return
×
UNCOV
1486
        }
×
1487
        dbDomain := p.FindDomainFromRequest(w, r)
6✔
1488
        if dbDomain == nil {
6✔
1489
                return
×
1490
        }
×
1491
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
6✔
1492
        if dbProject == nil {
6✔
1493
                return
×
UNCOV
1494
        }
×
1495
        var Request struct {
6✔
1496
                Duration limesresources.CommitmentDuration `json:"duration"`
6✔
1497
        }
6✔
1498
        req := Request
6✔
1499
        if !RequireJSON(w, r, &req) {
6✔
UNCOV
1500
                return
×
UNCOV
1501
        }
×
1502

1503
        var dbCommitment db.ProjectCommitment
6✔
1504
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, commitmentID, dbProject.ID)
6✔
1505
        if errors.Is(err, sql.ErrNoRows) {
6✔
UNCOV
1506
                http.Error(w, "no such commitment", http.StatusNotFound)
×
UNCOV
1507
                return
×
1508
        } else if respondwith.ErrorText(w, err) {
6✔
UNCOV
1509
                return
×
UNCOV
1510
        }
×
1511

1512
        now := p.timeNow()
6✔
1513
        if dbCommitment.ExpiresAt.Before(now) || dbCommitment.ExpiresAt.Equal(now) {
7✔
1514
                http.Error(w, "unable to process expired commitment", http.StatusForbidden)
1✔
1515
                return
1✔
1516
        }
1✔
1517

1518
        if dbCommitment.State == db.CommitmentStateSuperseded {
6✔
1519
                msg := fmt.Sprintf("unable to operate on commitment with a state of %s", dbCommitment.State)
1✔
1520
                http.Error(w, msg, http.StatusForbidden)
1✔
1521
                return
1✔
1522
        }
1✔
1523

1524
        var loc core.AZResourceLocation
4✔
1525
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
4✔
1526
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
4✔
1527
        if errors.Is(err, sql.ErrNoRows) {
4✔
UNCOV
1528
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
UNCOV
1529
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
UNCOV
1530
                return
×
1531
        } else if respondwith.ErrorText(w, err) {
4✔
UNCOV
1532
                return
×
UNCOV
1533
        }
×
1534
        behavior := p.Cluster.BehaviorForResource(loc.ServiceType, loc.ResourceName)
4✔
1535
        if !slices.Contains(behavior.CommitmentDurations, req.Duration) {
5✔
1536
                msg := fmt.Sprintf("provided duration: %s does not match the config %v", req.Duration, behavior.CommitmentDurations)
1✔
1537
                http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
1538
                return
1✔
1539
        }
1✔
1540

1541
        newExpiresAt := req.Duration.AddTo(unwrapOrDefault(dbCommitment.ConfirmBy, dbCommitment.CreatedAt))
3✔
1542
        if newExpiresAt.Before(dbCommitment.ExpiresAt) {
4✔
1543
                msg := fmt.Sprintf("duration change from %s to %s forbidden", dbCommitment.Duration, req.Duration)
1✔
1544
                http.Error(w, msg, http.StatusForbidden)
1✔
1545
                return
1✔
1546
        }
1✔
1547

1548
        dbCommitment.Duration = req.Duration
2✔
1549
        dbCommitment.ExpiresAt = newExpiresAt
2✔
1550
        _, err = p.DB.Update(&dbCommitment)
2✔
1551
        if respondwith.ErrorText(w, err) {
2✔
UNCOV
1552
                return
×
UNCOV
1553
        }
×
1554

1555
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token)
2✔
1556
        p.auditor.Record(audittools.Event{
2✔
1557
                Time:       p.timeNow(),
2✔
1558
                Request:    r,
2✔
1559
                User:       token,
2✔
1560
                ReasonCode: http.StatusOK,
2✔
1561
                Action:     cadf.UpdateAction,
2✔
1562
                Target: commitmentEventTarget{
2✔
1563
                        DomainID:    dbDomain.UUID,
2✔
1564
                        DomainName:  dbDomain.Name,
2✔
1565
                        ProjectID:   dbProject.UUID,
2✔
1566
                        ProjectName: dbProject.Name,
2✔
1567
                        Commitments: []limesresources.Commitment{c},
2✔
1568
                },
2✔
1569
        })
2✔
1570

2✔
1571
        respondwith.JSON(w, http.StatusOK, map[string]any{"commitment": c})
2✔
1572
}
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