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

sapcc / limes / 14023736838

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

Pull #674

github

majewsky
review: ExpiresAt always exists, so we do not need a fallback value
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(commitment.ExpiresAt),
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 {
6✔
1247
                result := strings.Compare(string(lhs.TargetService), string(rhs.TargetService))
4✔
1248
                if result != 0 {
7✔
1249
                        return result
3✔
1250
                }
3✔
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