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

sapcc / limes / 13674116679

05 Mar 2025 10:41AM UTC coverage: 79.455% (+0.03%) from 79.426%
13674116679

Pull #674

github

VoigtS
sort map keys to fix inconsistent unit tests
Pull Request #674: Add commitment renewal endpoint

111 of 137 new or added lines in 2 files covered. (81.02%)

199 existing lines in 3 files now uncovered.

6033 of 7593 relevant lines covered (79.45%)

61.53 hits per line

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

79.51
/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/gopherpolicy"
39
        "github.com/sapcc/go-bits/httpapi"
40
        "github.com/sapcc/go-bits/must"
41
        "github.com/sapcc/go-bits/respondwith"
42
        "github.com/sapcc/go-bits/sqlext"
43

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

24✔
443
        // if the commitment is immediately confirmed, trigger a capacity scrape in
24✔
444
        // order to ApplyComputedProjectQuotas based on the new commitment
24✔
445
        if dbCommitment.ConfirmedAt != nil {
42✔
446
                _, err := p.DB.Exec(forceImmediateCapacityScrapeQuery, now, loc.ServiceType, loc.ResourceName)
18✔
447
                if respondwith.ErrorText(w, err) {
18✔
UNCOV
448
                        return
×
UNCOV
449
                }
×
450
        }
451

452
        // display the possibly confirmed commitment to the user
453
        err = p.DB.SelectOne(&dbCommitment, `SELECT * FROM project_commitments WHERE id = $1`, dbCommitment.ID)
24✔
454
        if respondwith.ErrorText(w, err) {
24✔
UNCOV
455
                return
×
UNCOV
456
        }
×
457

458
        c := p.convertCommitmentToDisplayForm(dbCommitment, *loc, token)
24✔
459
        respondwith.JSON(w, http.StatusCreated, map[string]any{"commitment": c})
24✔
460
}
461

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

489
        // Load commitments
490
        dbCommitments := make([]db.ProjectCommitment, len(commitmentIDs))
8✔
491
        for i, commitmentID := range commitmentIDs {
24✔
492
                err := p.DB.SelectOne(&dbCommitments[i], findProjectCommitmentByIDQuery, commitmentID, dbProject.ID)
16✔
493
                if errors.Is(err, sql.ErrNoRows) {
17✔
494
                        http.Error(w, "no such commitment", http.StatusNotFound)
1✔
495
                        return
1✔
496
                } else if respondwith.ErrorText(w, err) {
16✔
UNCOV
497
                        return
×
UNCOV
498
                }
×
499
        }
500

501
        // Verify that all commitments agree on resource and AZ and are active
502
        azResourceID := dbCommitments[0].AZResourceID
7✔
503
        for _, dbCommitment := range dbCommitments {
21✔
504
                if dbCommitment.AZResourceID != azResourceID {
16✔
505
                        http.Error(w, "all commitments must be on the same resource and AZ", http.StatusConflict)
2✔
506
                        return
2✔
507
                }
2✔
508
                if dbCommitment.State != db.CommitmentStateActive {
16✔
509
                        http.Error(w, "only active commitments may be merged", http.StatusConflict)
4✔
510
                        return
4✔
511
                }
4✔
512
        }
513

514
        var loc core.AZResourceLocation
1✔
515
        err := p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, azResourceID).
1✔
516
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
1✔
517
        if errors.Is(err, sql.ErrNoRows) {
1✔
UNCOV
518
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
UNCOV
519
                return
×
520
        } else if respondwith.ErrorText(w, err) {
1✔
521
                return
×
UNCOV
522
        }
×
523

524
        // Start transaction for creating new commitment and marking merged commitments as superseded
525
        tx, err := p.DB.Begin()
1✔
526
        if respondwith.ErrorText(w, err) {
1✔
UNCOV
527
                return
×
UNCOV
528
        }
×
529
        defer sqlext.RollbackUnlessCommitted(tx)
1✔
530

1✔
531
        // Create merged template
1✔
532
        now := p.timeNow()
1✔
533
        dbMergedCommitment := db.ProjectCommitment{
1✔
534
                AZResourceID: azResourceID,
1✔
535
                Amount:       0,                                   // overwritten below
1✔
536
                Duration:     limesresources.CommitmentDuration{}, // overwritten below
1✔
537
                CreatedAt:    now,
1✔
538
                CreatorUUID:  token.UserUUID(),
1✔
539
                CreatorName:  fmt.Sprintf("%s@%s", token.UserName(), token.UserDomainName()),
1✔
540
                ConfirmedAt:  &now,
1✔
541
                ExpiresAt:    time.Time{}, // overwritten below
1✔
542
                State:        db.CommitmentStateActive,
1✔
543
        }
1✔
544

1✔
545
        // Fill amount and latest expiration date
1✔
546
        for _, dbCommitment := range dbCommitments {
3✔
547
                dbMergedCommitment.Amount += dbCommitment.Amount
2✔
548
                if dbCommitment.ExpiresAt.After(dbMergedCommitment.ExpiresAt) {
4✔
549
                        dbMergedCommitment.ExpiresAt = dbCommitment.ExpiresAt
2✔
550
                        dbMergedCommitment.Duration = dbCommitment.Duration
2✔
551
                }
2✔
552
        }
553

554
        // Fill workflow context
555
        creationContext := db.CommitmentWorkflowContext{
1✔
556
                Reason:               db.CommitmentReasonMerge,
1✔
557
                RelatedCommitmentIDs: commitmentIDs,
1✔
558
        }
1✔
559
        buf, err := json.Marshal(creationContext)
1✔
560
        if respondwith.ErrorText(w, err) {
1✔
UNCOV
561
                return
×
UNCOV
562
        }
×
563
        dbMergedCommitment.CreationContextJSON = json.RawMessage(buf)
1✔
564

1✔
565
        // Insert into database
1✔
566
        err = tx.Insert(&dbMergedCommitment)
1✔
567
        if respondwith.ErrorText(w, err) {
1✔
UNCOV
568
                return
×
UNCOV
569
        }
×
570

571
        // Mark merged commits as superseded
572
        supersedeContext := db.CommitmentWorkflowContext{
1✔
573
                Reason:               db.CommitmentReasonMerge,
1✔
574
                RelatedCommitmentIDs: []db.ProjectCommitmentID{dbMergedCommitment.ID},
1✔
575
        }
1✔
576
        buf, err = json.Marshal(supersedeContext)
1✔
577
        if respondwith.ErrorText(w, err) {
1✔
UNCOV
578
                return
×
UNCOV
579
        }
×
580
        for _, dbCommitment := range dbCommitments {
3✔
581
                dbCommitment.SupersededAt = &now
2✔
582
                dbCommitment.SupersedeContextJSON = liquids.PointerTo(json.RawMessage(buf))
2✔
583
                dbCommitment.State = db.CommitmentStateSuperseded
2✔
584
                _, err = tx.Update(&dbCommitment)
2✔
585
                if respondwith.ErrorText(w, err) {
2✔
UNCOV
586
                        return
×
UNCOV
587
                }
×
588
        }
589

590
        err = tx.Commit()
1✔
591
        if respondwith.ErrorText(w, err) {
1✔
UNCOV
592
                return
×
UNCOV
593
        }
×
594

595
        c := p.convertCommitmentToDisplayForm(dbMergedCommitment, loc, token)
1✔
596
        auditEvent := commitmentEventTarget{
1✔
597
                DomainID:        dbDomain.UUID,
1✔
598
                DomainName:      dbDomain.Name,
1✔
599
                ProjectID:       dbProject.UUID,
1✔
600
                ProjectName:     dbProject.Name,
1✔
601
                Commitments:     []limesresources.Commitment{c},
1✔
602
                WorkflowContext: &creationContext,
1✔
603
        }
1✔
604
        p.auditor.Record(audittools.Event{
1✔
605
                Time:       p.timeNow(),
1✔
606
                Request:    r,
1✔
607
                User:       token,
1✔
608
                ReasonCode: http.StatusAccepted,
1✔
609
                Action:     cadf.UpdateAction,
1✔
610
                Target:     auditEvent,
1✔
611
        })
1✔
612

1✔
613
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
1✔
614
}
615

616
// RenewProjectCommitments handles POST /v1/domains/:domain_id/projects/:project_id/commitments/renew.
617
func (p *v1Provider) RenewProjectCommitments(w http.ResponseWriter, r *http.Request) {
6✔
618
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/renew")
6✔
619
        token := p.CheckToken(r)
6✔
620
        if !token.Require(w, "project:edit") {
6✔
NEW
UNCOV
621
                return
×
NEW
UNCOV
622
        }
×
623
        dbDomain := p.FindDomainFromRequest(w, r)
6✔
624
        if dbDomain == nil {
6✔
NEW
625
                return
×
NEW
626
        }
×
627
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
6✔
628
        if dbProject == nil {
6✔
NEW
629
                return
×
NEW
630
        }
×
631
        var parseTarget struct {
6✔
632
                CommitmentIDs []db.ProjectCommitmentID `json:"commitment_ids"`
6✔
633
        }
6✔
634
        if !RequireJSON(w, r, &parseTarget) {
6✔
NEW
635
                return
×
NEW
636
        }
×
637

638
        // Load commitments
639
        commitmentIDs := parseTarget.CommitmentIDs
6✔
640
        dbCommitments := make([]db.ProjectCommitment, len(commitmentIDs))
6✔
641
        for i, commitmentID := range commitmentIDs {
14✔
642
                err := p.DB.SelectOne(&dbCommitments[i], findProjectCommitmentByIDQuery, commitmentID, dbProject.ID)
8✔
643
                if errors.Is(err, sql.ErrNoRows) {
8✔
NEW
644
                        http.Error(w, "no such commitment", http.StatusNotFound)
×
NEW
645
                        return
×
646
                } else if respondwith.ErrorText(w, err) {
8✔
NEW
647
                        return
×
NEW
648
                }
×
649
        }
650
        now := p.timeNow()
6✔
651

6✔
652
        // Check if commitments are renewable
6✔
653
        for _, dbCommitment := range dbCommitments {
13✔
654
                msg := []string{fmt.Sprintf("CommitmentID: %v", dbCommitment.ID)}
7✔
655
                if dbCommitment.State != db.CommitmentStateActive {
8✔
656
                        msg = append(msg, fmt.Sprintf("invalid commitment state: %s", dbCommitment.State))
1✔
657
                }
1✔
658
                if now.Before(dbCommitment.ExpiresAt.Add(-(3 * 30 * 24 * time.Hour))) {
9✔
659
                        msg = append(msg, "renewal attempt too early")
2✔
660
                }
2✔
661
                if now.After(dbCommitment.ExpiresAt) {
8✔
662
                        msg = append(msg, "commitment expired")
1✔
663
                }
1✔
664
                if dbCommitment.TransferStatus != limesresources.CommitmentTransferStatusNone {
8✔
665
                        msg = append(msg, "commitment in transfer")
1✔
666
                }
1✔
667
                if dbCommitment.WasExtended {
8✔
668
                        msg = append(msg, "commitment already renewed")
1✔
669
                }
1✔
670

671
                if len(msg) > 1 {
12✔
672
                        http.Error(w, strings.Join(msg, " - "), http.StatusConflict)
5✔
673
                        return
5✔
674
                }
5✔
675
        }
676

677
        // Create renewed commitments
678
        tx, err := p.DB.Begin()
1✔
679
        if respondwith.ErrorText(w, err) {
1✔
NEW
680
                return
×
NEW
681
        }
×
682
        defer sqlext.RollbackUnlessCommitted(tx)
1✔
683

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

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

2✔
722
                err = tx.Insert(&dbRenewedCommitment)
2✔
723
                if respondwith.ErrorText(w, err) {
2✔
NEW
724
                        return
×
NEW
725
                }
×
726
                dbRenewedCommitments[dbRenewedCommitment.ID] = renewContext{commitment: dbRenewedCommitment, location: loc, context: creationContext}
2✔
727

2✔
728
                commitment.WasExtended = true
2✔
729
                _, err = tx.Update(&commitment)
2✔
730
                if respondwith.ErrorText(w, err) {
2✔
NEW
731
                        return
×
NEW
732
                }
×
733
        }
734

735
        err = tx.Commit()
1✔
736
        if respondwith.ErrorText(w, err) {
1✔
NEW
737
                return
×
NEW
738
        }
×
739

740
        // Create resultset and auditlogs
741
        var commitments []limesresources.Commitment
1✔
742
        for _, key := range slices.Sorted(maps.Keys(dbRenewedCommitments)) {
3✔
743
                ctx := dbRenewedCommitments[key]
2✔
744
                c := p.convertCommitmentToDisplayForm(ctx.commitment, ctx.location, token)
2✔
745
                commitments = append(commitments, c)
2✔
746
                auditEvent := commitmentEventTarget{
2✔
747
                        DomainID:        dbDomain.UUID,
2✔
748
                        DomainName:      dbDomain.Name,
2✔
749
                        ProjectID:       dbProject.UUID,
2✔
750
                        ProjectName:     dbProject.Name,
2✔
751
                        Commitments:     []limesresources.Commitment{c},
2✔
752
                        WorkflowContext: &ctx.context,
2✔
753
                }
2✔
754

2✔
755
                p.auditor.Record(audittools.Event{
2✔
756
                        Time:       p.timeNow(),
2✔
757
                        Request:    r,
2✔
758
                        User:       token,
2✔
759
                        ReasonCode: http.StatusAccepted,
2✔
760
                        Action:     cadf.UpdateAction,
2✔
761
                        Target:     auditEvent,
2✔
762
                })
2✔
763
        }
2✔
764

765
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitments": commitments})
1✔
766
}
767

768
// DeleteProjectCommitment handles DELETE /v1/domains/:domain_id/projects/:project_id/commitments/:id.
769
func (p *v1Provider) DeleteProjectCommitment(w http.ResponseWriter, r *http.Request) {
8✔
770
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/:id")
8✔
771
        token := p.CheckToken(r)
8✔
772
        if !token.Require(w, "project:edit") { //NOTE: There is a more specific AuthZ check further down below.
8✔
UNCOV
773
                return
×
UNCOV
774
        }
×
775
        dbDomain := p.FindDomainFromRequest(w, r)
8✔
776
        if dbDomain == nil {
9✔
777
                return
1✔
778
        }
1✔
779
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
7✔
780
        if dbProject == nil {
8✔
781
                return
1✔
782
        }
1✔
783

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

804
        // check authorization for this specific commitment
805
        if !p.canDeleteCommitment(token, dbCommitment) {
6✔
806
                http.Error(w, "Forbidden", http.StatusForbidden)
1✔
807
                return
1✔
808
        }
1✔
809

810
        // perform deletion
811
        _, err = p.DB.Delete(&dbCommitment)
4✔
812
        if respondwith.ErrorText(w, err) {
4✔
UNCOV
813
                return
×
UNCOV
814
        }
×
815
        p.auditor.Record(audittools.Event{
4✔
816
                Time:       p.timeNow(),
4✔
817
                Request:    r,
4✔
818
                User:       token,
4✔
819
                ReasonCode: http.StatusNoContent,
4✔
820
                Action:     cadf.DeleteAction,
4✔
821
                Target: commitmentEventTarget{
4✔
822
                        DomainID:    dbDomain.UUID,
4✔
823
                        DomainName:  dbDomain.Name,
4✔
824
                        ProjectID:   dbProject.UUID,
4✔
825
                        ProjectName: dbProject.Name,
4✔
826
                        Commitments: []limesresources.Commitment{p.convertCommitmentToDisplayForm(dbCommitment, loc, token)},
4✔
827
                },
4✔
828
        })
4✔
829

4✔
830
        w.WriteHeader(http.StatusNoContent)
4✔
831
}
832

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

845
        // afterwards, a more specific permission is required to delete it
846
        //
847
        // This protects cloud admins making capacity planning decisions based on future commitments
848
        // from having their forecasts ruined by project admins suffering from buyer's remorse.
849
        return token.Check("project:uncommit")
24✔
850
}
851

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

8✔
879
        if req.TransferStatus != limesresources.CommitmentTransferStatusUnlisted && req.TransferStatus != limesresources.CommitmentTransferStatusPublic {
8✔
UNCOV
880
                http.Error(w, fmt.Sprintf("Invalid transfer_status code. Must be %s or %s.", limesresources.CommitmentTransferStatusUnlisted, limesresources.CommitmentTransferStatusPublic), http.StatusBadRequest)
×
881
                return
×
882
        }
×
883

884
        if req.Amount <= 0 {
9✔
885
                http.Error(w, "delivered amount needs to be a positive value.", http.StatusBadRequest)
1✔
886
                return
1✔
887
        }
1✔
888

889
        // load commitment
890
        var dbCommitment db.ProjectCommitment
7✔
891
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, mux.Vars(r)["id"], dbProject.ID)
7✔
892
        if errors.Is(err, sql.ErrNoRows) {
7✔
UNCOV
893
                http.Error(w, "no such commitment", http.StatusNotFound)
×
UNCOV
894
                return
×
895
        } else if respondwith.ErrorText(w, err) {
7✔
UNCOV
896
                return
×
UNCOV
897
        }
×
898

899
        // Mark whole commitment or a newly created, splitted one as transferrable.
900
        tx, err := p.DB.Begin()
7✔
901
        if respondwith.ErrorText(w, err) {
7✔
UNCOV
902
                return
×
903
        }
×
904
        defer sqlext.RollbackUnlessCommitted(tx)
7✔
905
        transferToken := p.generateTransferToken()
7✔
906

7✔
907
        // Deny requests with a greater amount than the commitment.
7✔
908
        if req.Amount > dbCommitment.Amount {
8✔
909
                http.Error(w, "delivered amount exceeds the commitment amount.", http.StatusBadRequest)
1✔
910
                return
1✔
911
        }
1✔
912

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

964
        var loc core.AZResourceLocation
6✔
965
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
6✔
966
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
6✔
967
        if errors.Is(err, sql.ErrNoRows) {
6✔
UNCOV
968
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
UNCOV
969
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
UNCOV
970
                return
×
971
        } else if respondwith.ErrorText(w, err) {
6✔
UNCOV
972
                return
×
UNCOV
973
        }
×
974

975
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token)
6✔
976
        p.auditor.Record(audittools.Event{
6✔
977
                Time:       p.timeNow(),
6✔
978
                Request:    r,
6✔
979
                User:       token,
6✔
980
                ReasonCode: http.StatusAccepted,
6✔
981
                Action:     cadf.UpdateAction,
6✔
982
                Target: commitmentEventTarget{
6✔
983
                        DomainID:    dbDomain.UUID,
6✔
984
                        DomainName:  dbDomain.Name,
6✔
985
                        ProjectID:   dbProject.UUID,
6✔
986
                        ProjectName: dbProject.Name,
6✔
987
                        Commitments: []limesresources.Commitment{c},
6✔
988
                },
6✔
989
        })
6✔
990
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
6✔
991
}
992

993
func (p *v1Provider) buildSplitCommitment(dbCommitment db.ProjectCommitment, amount uint64) (db.ProjectCommitment, error) {
5✔
994
        now := p.timeNow()
5✔
995
        creationContext := db.CommitmentWorkflowContext{
5✔
996
                Reason:               db.CommitmentReasonSplit,
5✔
997
                RelatedCommitmentIDs: []db.ProjectCommitmentID{dbCommitment.ID},
5✔
998
        }
5✔
999
        buf, err := json.Marshal(creationContext)
5✔
1000
        if err != nil {
5✔
UNCOV
1001
                return db.ProjectCommitment{}, err
×
UNCOV
1002
        }
×
1003
        return db.ProjectCommitment{
5✔
1004
                AZResourceID:        dbCommitment.AZResourceID,
5✔
1005
                Amount:              amount,
5✔
1006
                Duration:            dbCommitment.Duration,
5✔
1007
                CreatedAt:           now,
5✔
1008
                CreatorUUID:         dbCommitment.CreatorUUID,
5✔
1009
                CreatorName:         dbCommitment.CreatorName,
5✔
1010
                ConfirmBy:           dbCommitment.ConfirmBy,
5✔
1011
                ConfirmedAt:         dbCommitment.ConfirmedAt,
5✔
1012
                ExpiresAt:           dbCommitment.ExpiresAt,
5✔
1013
                CreationContextJSON: json.RawMessage(buf),
5✔
1014
                State:               dbCommitment.State,
5✔
1015
        }, nil
5✔
1016
}
1017

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

1043
// GetCommitmentByTransferToken handles GET /v1/commitments/{token}
1044
func (p *v1Provider) GetCommitmentByTransferToken(w http.ResponseWriter, r *http.Request) {
2✔
1045
        httpapi.IdentifyEndpoint(r, "/v1/commitments/:token")
2✔
1046
        token := p.CheckToken(r)
2✔
1047
        if !token.Require(w, "cluster:show_basic") {
2✔
UNCOV
1048
                return
×
UNCOV
1049
        }
×
1050
        transferToken := mux.Vars(r)["token"]
2✔
1051

2✔
1052
        // The token column is a unique key, so we expect only one result.
2✔
1053
        var dbCommitment db.ProjectCommitment
2✔
1054
        err := p.DB.SelectOne(&dbCommitment, findCommitmentByTransferToken, transferToken)
2✔
1055
        if errors.Is(err, sql.ErrNoRows) {
3✔
1056
                http.Error(w, "no matching commitment found.", http.StatusNotFound)
1✔
1057
                return
1✔
1058
        } else if respondwith.ErrorText(w, err) {
2✔
UNCOV
1059
                return
×
UNCOV
1060
        }
×
1061

1062
        var loc core.AZResourceLocation
1✔
1063
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
1✔
1064
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
1✔
1065
        if errors.Is(err, sql.ErrNoRows) {
1✔
UNCOV
1066
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
1067
                http.Error(w, "location data not found.", http.StatusNotFound)
×
1068
                return
×
1069
        } else if respondwith.ErrorText(w, err) {
1✔
UNCOV
1070
                return
×
UNCOV
1071
        }
×
1072

1073
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token)
1✔
1074
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
1✔
1075
}
1076

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

1103
        // find commitment by transfer_token
1104
        var dbCommitment db.ProjectCommitment
4✔
1105
        err := p.DB.SelectOne(&dbCommitment, getCommitmentWithMatchingTransferTokenQuery, commitmentID, transferToken)
4✔
1106
        if errors.Is(err, sql.ErrNoRows) {
5✔
1107
                http.Error(w, "no matching commitment found", http.StatusNotFound)
1✔
1108
                return
1✔
1109
        } else if respondwith.ErrorText(w, err) {
4✔
UNCOV
1110
                return
×
UNCOV
1111
        }
×
1112

1113
        var loc core.AZResourceLocation
3✔
1114
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
3✔
1115
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
3✔
1116
        if errors.Is(err, sql.ErrNoRows) {
3✔
UNCOV
1117
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
1118
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
1119
                return
×
1120
        } else if respondwith.ErrorText(w, err) {
3✔
UNCOV
1121
                return
×
UNCOV
1122
        }
×
1123

1124
        // get target service and AZ resource
1125
        var (
3✔
1126
                sourceResourceID   db.ProjectResourceID
3✔
1127
                targetResourceID   db.ProjectResourceID
3✔
1128
                targetAZResourceID db.ProjectAZResourceID
3✔
1129
        )
3✔
1130
        err = p.DB.QueryRow(findTargetAZResourceIDBySourceIDQuery, dbCommitment.AZResourceID, targetProject.ID).
3✔
1131
                Scan(&sourceResourceID, &targetResourceID, &targetAZResourceID)
3✔
1132
        if respondwith.ErrorText(w, err) {
3✔
UNCOV
1133
                return
×
UNCOV
1134
        }
×
1135

1136
        // validate that we have enough committable capacity on the receiving side
1137
        tx, err := p.DB.Begin()
3✔
1138
        if respondwith.ErrorText(w, err) {
3✔
UNCOV
1139
                return
×
UNCOV
1140
        }
×
1141
        defer sqlext.RollbackUnlessCommitted(tx)
3✔
1142
        ok, err := datamodel.CanMoveExistingCommitment(dbCommitment.Amount, loc, sourceResourceID, targetResourceID, p.Cluster, tx)
3✔
1143
        if respondwith.ErrorText(w, err) {
3✔
UNCOV
1144
                return
×
1145
        }
×
1146
        if !ok {
4✔
1147
                http.Error(w, "not enough committable capacity on the receiving side", http.StatusConflict)
1✔
1148
                return
1✔
1149
        }
1✔
1150

1151
        dbCommitment.TransferStatus = ""
2✔
1152
        dbCommitment.TransferToken = nil
2✔
1153
        dbCommitment.AZResourceID = targetAZResourceID
2✔
1154
        _, err = tx.Update(&dbCommitment)
2✔
1155
        if respondwith.ErrorText(w, err) {
2✔
UNCOV
1156
                return
×
UNCOV
1157
        }
×
1158
        err = tx.Commit()
2✔
1159
        if respondwith.ErrorText(w, err) {
2✔
UNCOV
1160
                return
×
UNCOV
1161
        }
×
1162

1163
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token)
2✔
1164
        p.auditor.Record(audittools.Event{
2✔
1165
                Time:       p.timeNow(),
2✔
1166
                Request:    r,
2✔
1167
                User:       token,
2✔
1168
                ReasonCode: http.StatusAccepted,
2✔
1169
                Action:     cadf.UpdateAction,
2✔
1170
                Target: commitmentEventTarget{
2✔
1171
                        DomainID:    dbDomain.UUID,
2✔
1172
                        DomainName:  dbDomain.Name,
2✔
1173
                        ProjectID:   targetProject.UUID,
2✔
1174
                        ProjectName: targetProject.Name,
2✔
1175
                        Commitments: []limesresources.Commitment{c},
2✔
1176
                },
2✔
1177
        })
2✔
1178

2✔
1179
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
2✔
1180
}
1181

1182
// GetCommitmentConversion handles GET /v1/commitment-conversion/{service_type}/{resource_name}
1183
func (p *v1Provider) GetCommitmentConversions(w http.ResponseWriter, r *http.Request) {
2✔
1184
        httpapi.IdentifyEndpoint(r, "/v1/commitment-conversion/:service_type/:resource_name")
2✔
1185
        token := p.CheckToken(r)
2✔
1186
        if !token.Require(w, "cluster:show_basic") {
2✔
UNCOV
1187
                return
×
UNCOV
1188
        }
×
1189

1190
        // validate request
1191
        vars := mux.Vars(r)
2✔
1192
        nm := core.BuildResourceNameMapping(p.Cluster)
2✔
1193
        sourceServiceType, sourceResourceName, exists := nm.MapFromV1API(
2✔
1194
                limes.ServiceType(vars["service_type"]),
2✔
1195
                limesresources.ResourceName(vars["resource_name"]),
2✔
1196
        )
2✔
1197
        if !exists {
2✔
UNCOV
1198
                msg := fmt.Sprintf("no such service and/or resource: %s/%s", vars["service_type"], vars["resource_name"])
×
UNCOV
1199
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
UNCOV
1200
                return
×
UNCOV
1201
        }
×
1202
        sourceBehavior := p.Cluster.BehaviorForResource(sourceServiceType, sourceResourceName)
2✔
1203
        sourceResInfo := p.Cluster.InfoForResource(sourceServiceType, sourceResourceName)
2✔
1204

2✔
1205
        // enumerate possible conversions
2✔
1206
        conversions := make([]limesresources.CommitmentConversionRule, 0)
2✔
1207
        for targetServiceType, quotaPlugin := range p.Cluster.QuotaPlugins {
8✔
1208
                for targetResourceName, targetResInfo := range quotaPlugin.Resources() {
28✔
1209
                        targetBehavior := p.Cluster.BehaviorForResource(targetServiceType, targetResourceName)
22✔
1210
                        if targetBehavior.CommitmentConversion == (core.CommitmentConversion{}) {
30✔
1211
                                continue
8✔
1212
                        }
1213
                        if sourceServiceType == targetServiceType && sourceResourceName == targetResourceName {
16✔
1214
                                continue
2✔
1215
                        }
1216
                        if sourceResInfo.Unit != targetResInfo.Unit {
19✔
1217
                                continue
7✔
1218
                        }
1219
                        if sourceBehavior.CommitmentConversion.Identifier != targetBehavior.CommitmentConversion.Identifier {
6✔
1220
                                continue
1✔
1221
                        }
1222

1223
                        fromAmount, toAmount := p.getCommitmentConversionRate(sourceBehavior, targetBehavior)
4✔
1224
                        apiServiceType, apiResourceName, ok := nm.MapToV1API(targetServiceType, targetResourceName)
4✔
1225
                        if ok {
8✔
1226
                                conversions = append(conversions, limesresources.CommitmentConversionRule{
4✔
1227
                                        FromAmount:     fromAmount,
4✔
1228
                                        ToAmount:       toAmount,
4✔
1229
                                        TargetService:  apiServiceType,
4✔
1230
                                        TargetResource: apiResourceName,
4✔
1231
                                })
4✔
1232
                        }
4✔
1233
                }
1234
        }
1235

1236
        // use a defined sorting to ensure deterministic behavior in tests
1237
        slices.SortFunc(conversions, func(lhs, rhs limesresources.CommitmentConversionRule) int {
8✔
1238
                result := strings.Compare(string(lhs.TargetService), string(rhs.TargetService))
6✔
1239
                if result != 0 {
11✔
1240
                        return result
5✔
1241
                }
5✔
1242
                return strings.Compare(string(lhs.TargetResource), string(rhs.TargetResource))
1✔
1243
        })
1244

1245
        respondwith.JSON(w, http.StatusOK, map[string]any{"conversions": conversions})
2✔
1246
}
1247

1248
// ConvertCommitment handles POST /v1/domains/{domain_id}/projects/{project_id}/commitments/{commitment_id}/convert
1249
func (p *v1Provider) ConvertCommitment(w http.ResponseWriter, r *http.Request) {
9✔
1250
        httpapi.IdentifyEndpoint(r, "/v1/domains/:domain_id/projects/:project_id/commitments/:commitment_id/convert")
9✔
1251
        token := p.CheckToken(r)
9✔
1252
        if !token.Require(w, "project:edit") {
9✔
UNCOV
1253
                return
×
UNCOV
1254
        }
×
1255
        commitmentID := mux.Vars(r)["commitment_id"]
9✔
1256
        if commitmentID == "" {
9✔
UNCOV
1257
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
×
UNCOV
1258
                return
×
UNCOV
1259
        }
×
1260
        dbDomain := p.FindDomainFromRequest(w, r)
9✔
1261
        if dbDomain == nil {
9✔
1262
                return
×
UNCOV
1263
        }
×
1264
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
9✔
1265
        if dbProject == nil {
9✔
UNCOV
1266
                return
×
1267
        }
×
1268

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

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

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

1346
        tx, err := p.DB.Begin()
3✔
1347
        if respondwith.ErrorText(w, err) {
3✔
1348
                return
×
1349
        }
×
1350
        defer sqlext.RollbackUnlessCommitted(tx)
3✔
1351

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

1384
        auditEvent := commitmentEventTarget{
2✔
1385
                DomainID:    dbDomain.UUID,
2✔
1386
                DomainName:  dbDomain.Name,
2✔
1387
                ProjectID:   dbProject.UUID,
2✔
1388
                ProjectName: dbProject.Name,
2✔
1389
        }
2✔
1390

2✔
1391
        relatedCommitmentIDs := make([]db.ProjectCommitmentID, 0)
2✔
1392
        remainingAmount := dbCommitment.Amount - req.SourceAmount
2✔
1393
        if remainingAmount > 0 {
3✔
1394
                remainingCommitment, err := p.buildSplitCommitment(dbCommitment, remainingAmount)
1✔
1395
                if respondwith.ErrorText(w, err) {
1✔
1396
                        return
×
UNCOV
1397
                }
×
1398
                relatedCommitmentIDs = append(relatedCommitmentIDs, remainingCommitment.ID)
1✔
1399
                err = tx.Insert(&remainingCommitment)
1✔
1400
                if respondwith.ErrorText(w, err) {
1✔
1401
                        return
×
UNCOV
1402
                }
×
1403
                auditEvent.Commitments = append(auditEvent.Commitments,
1✔
1404
                        p.convertCommitmentToDisplayForm(remainingCommitment, sourceLoc, token),
1✔
1405
                )
1✔
1406
        }
1407

1408
        convertedCommitment, err := p.buildConvertedCommitment(dbCommitment, targetAZResourceID, conversionAmount)
2✔
1409
        if respondwith.ErrorText(w, err) {
2✔
UNCOV
1410
                return
×
1411
        }
×
1412
        relatedCommitmentIDs = append(relatedCommitmentIDs, convertedCommitment.ID)
2✔
1413
        err = tx.Insert(&convertedCommitment)
2✔
1414
        if respondwith.ErrorText(w, err) {
2✔
UNCOV
1415
                return
×
UNCOV
1416
        }
×
1417

1418
        // supersede the original commitment
1419
        now := p.timeNow()
2✔
1420
        supersedeContext := db.CommitmentWorkflowContext{
2✔
1421
                Reason:               db.CommitmentReasonConvert,
2✔
1422
                RelatedCommitmentIDs: relatedCommitmentIDs,
2✔
1423
        }
2✔
1424
        buf, err := json.Marshal(supersedeContext)
2✔
1425
        if respondwith.ErrorText(w, err) {
2✔
UNCOV
1426
                return
×
UNCOV
1427
        }
×
1428
        dbCommitment.State = db.CommitmentStateSuperseded
2✔
1429
        dbCommitment.SupersededAt = &now
2✔
1430
        dbCommitment.SupersedeContextJSON = liquids.PointerTo(json.RawMessage(buf))
2✔
1431
        _, err = tx.Update(&dbCommitment)
2✔
1432
        if respondwith.ErrorText(w, err) {
2✔
UNCOV
1433
                return
×
UNCOV
1434
        }
×
1435

1436
        err = tx.Commit()
2✔
1437
        if respondwith.ErrorText(w, err) {
2✔
UNCOV
1438
                return
×
UNCOV
1439
        }
×
1440

1441
        c := p.convertCommitmentToDisplayForm(convertedCommitment, targetLoc, token)
2✔
1442
        auditEvent.Commitments = append([]limesresources.Commitment{c}, auditEvent.Commitments...)
2✔
1443
        auditEvent.WorkflowContext = &db.CommitmentWorkflowContext{
2✔
1444
                Reason:               db.CommitmentReasonSplit,
2✔
1445
                RelatedCommitmentIDs: []db.ProjectCommitmentID{dbCommitment.ID},
2✔
1446
        }
2✔
1447
        p.auditor.Record(audittools.Event{
2✔
1448
                Time:       p.timeNow(),
2✔
1449
                Request:    r,
2✔
1450
                User:       token,
2✔
1451
                ReasonCode: http.StatusAccepted,
2✔
1452
                Action:     cadf.UpdateAction,
2✔
1453
                Target:     auditEvent,
2✔
1454
        })
2✔
1455

2✔
1456
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
2✔
1457
}
1458

1459
func (p *v1Provider) getCommitmentConversionRate(source, target core.ResourceBehavior) (fromAmount, toAmount uint64) {
10✔
1460
        divisor := GetGreatestCommonDivisor(source.CommitmentConversion.Weight, target.CommitmentConversion.Weight)
10✔
1461
        fromAmount = target.CommitmentConversion.Weight / divisor
10✔
1462
        toAmount = source.CommitmentConversion.Weight / divisor
10✔
1463
        return fromAmount, toAmount
10✔
1464
}
10✔
1465

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

1494
        var dbCommitment db.ProjectCommitment
6✔
1495
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, commitmentID, dbProject.ID)
6✔
1496
        if errors.Is(err, sql.ErrNoRows) {
6✔
UNCOV
1497
                http.Error(w, "no such commitment", http.StatusNotFound)
×
UNCOV
1498
                return
×
1499
        } else if respondwith.ErrorText(w, err) {
6✔
UNCOV
1500
                return
×
UNCOV
1501
        }
×
1502

1503
        now := p.timeNow()
6✔
1504
        if dbCommitment.ExpiresAt.Before(now) || dbCommitment.ExpiresAt.Equal(now) {
7✔
1505
                http.Error(w, "unable to process expired commitment", http.StatusForbidden)
1✔
1506
                return
1✔
1507
        }
1✔
1508

1509
        if dbCommitment.State == db.CommitmentStateSuperseded {
6✔
1510
                msg := fmt.Sprintf("unable to operate on commitment with a state of %s", dbCommitment.State)
1✔
1511
                http.Error(w, msg, http.StatusForbidden)
1✔
1512
                return
1✔
1513
        }
1✔
1514

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

1532
        newExpiresAt := req.Duration.AddTo(unwrapOrDefault(dbCommitment.ConfirmBy, dbCommitment.CreatedAt))
3✔
1533
        if newExpiresAt.Before(dbCommitment.ExpiresAt) {
4✔
1534
                msg := fmt.Sprintf("duration change from %s to %s forbidden", dbCommitment.Duration, req.Duration)
1✔
1535
                http.Error(w, msg, http.StatusForbidden)
1✔
1536
                return
1✔
1537
        }
1✔
1538

1539
        dbCommitment.Duration = req.Duration
2✔
1540
        dbCommitment.ExpiresAt = newExpiresAt
2✔
1541
        _, err = p.DB.Update(&dbCommitment)
2✔
1542
        if respondwith.ErrorText(w, err) {
2✔
UNCOV
1543
                return
×
UNCOV
1544
        }
×
1545

1546
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token)
2✔
1547
        p.auditor.Record(audittools.Event{
2✔
1548
                Time:       p.timeNow(),
2✔
1549
                Request:    r,
2✔
1550
                User:       token,
2✔
1551
                ReasonCode: http.StatusOK,
2✔
1552
                Action:     cadf.UpdateAction,
2✔
1553
                Target: commitmentEventTarget{
2✔
1554
                        DomainID:    dbDomain.UUID,
2✔
1555
                        DomainName:  dbDomain.Name,
2✔
1556
                        ProjectID:   dbProject.UUID,
2✔
1557
                        ProjectName: dbProject.Name,
2✔
1558
                        Commitments: []limesresources.Commitment{c},
2✔
1559
                },
2✔
1560
        })
2✔
1561

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