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

sapcc / limes / 16569218550

28 Jul 2025 12:37PM UTC coverage: 78.908% (+0.4%) from 78.536%
16569218550

push

github

web-flow
Merge pull request #741 from sapcc/project_level_v2

541 of 610 new or added lines in 20 files covered. (88.69%)

18 existing lines in 10 files now uncovered.

6835 of 8662 relevant lines covered (78.91%)

59.49 hits per line

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

77.24
/internal/api/commitment.go
1
// SPDX-FileCopyrightText: 2023 SAP SE or an SAP affiliate company
2
// SPDX-License-Identifier: Apache-2.0
3

4
package api
5

6
import (
7
        "cmp"
8
        "database/sql"
9
        "encoding/json"
10
        "errors"
11
        "fmt"
12
        "maps"
13
        "net/http"
14
        "slices"
15
        "strings"
16
        "time"
17

18
        "github.com/gorilla/mux"
19
        . "github.com/majewsky/gg/option"
20
        "github.com/majewsky/gg/options"
21
        "github.com/sapcc/go-api-declarations/cadf"
22
        "github.com/sapcc/go-api-declarations/limes"
23
        limesresources "github.com/sapcc/go-api-declarations/limes/resources"
24
        "github.com/sapcc/go-api-declarations/liquid"
25
        "github.com/sapcc/go-bits/audittools"
26
        "github.com/sapcc/go-bits/errext"
27
        "github.com/sapcc/go-bits/gopherpolicy"
28
        "github.com/sapcc/go-bits/httpapi"
29
        "github.com/sapcc/go-bits/must"
30
        "github.com/sapcc/go-bits/respondwith"
31
        "github.com/sapcc/go-bits/sqlext"
32

33
        "github.com/sapcc/limes/internal/core"
34
        "github.com/sapcc/limes/internal/datamodel"
35
        "github.com/sapcc/limes/internal/db"
36
        "github.com/sapcc/limes/internal/reports"
37
)
38

39
var (
40
        getProjectCommitmentsQuery = sqlext.SimplifyWhitespace(`
41
                SELECT pc.*
42
                  FROM project_commitments_v2 pc
43
                  JOIN cluster_az_resources cazr ON pc.az_resource_id = cazr.id
44
                  JOIN cluster_resources cr ON cazr.resource_id = cr.id {{AND cr.name = $resource_name}}
45
                  JOIN cluster_services cs ON cr.service_id = cs.id {{AND cs.type = $service_type}}
46
                 WHERE %s AND pc.state NOT IN ('superseded', 'expired')
47
                 ORDER BY pc.id
48
        `)
49

50
        getClusterAZResourceLocationsQuery = sqlext.SimplifyWhitespace(`
51
                SELECT cazr.id, cs.type, cr.name, cazr.az
52
                  FROM project_az_resources_v2 pazr
53
                  JOIN cluster_az_resources cazr on pazr.az_resource_id = cazr.id
54
                  JOIN cluster_resources cr ON cazr.resource_id = cr.id {{AND cr.name = $resource_name}}
55
                  JOIN cluster_services cs ON cr.service_id = cs.id {{AND cs.type = $service_type}}
56
                 WHERE %s
57
        `)
58

59
        findProjectCommitmentByIDQuery = sqlext.SimplifyWhitespace(`
60
                SELECT pc.*
61
                  FROM project_commitments_v2 pc
62
                 WHERE pc.id = $1 AND pc.project_id = $2
63
        `)
64

65
        findClusterAZResourceIDByLocationQuery = sqlext.SimplifyWhitespace(`
66
                SELECT cazr.id, pr.forbidden IS NOT TRUE as resource_allows_commitments
67
                  FROM cluster_az_resources cazr
68
                  JOIN cluster_resources cr ON cazr.resource_id = cr.id
69
                  JOIN cluster_services cs ON cr.service_id = cs.id
70
                  JOIN project_resources_v2 pr ON pr.resource_id = cr.id
71
                 WHERE pr.project_id = $1 AND cs.type = $2 AND cr.name = $3 AND cazr.az = $4
72
        `)
73

74
        findClusterAZResourceLocationByIDQuery = sqlext.SimplifyWhitespace(`
75
                SELECT cs.type, cr.name, cazr.az
76
                  FROM cluster_az_resources cazr
77
                  JOIN cluster_resources cr ON cazr.resource_id = cr.id
78
                  JOIN cluster_services cs ON cr.service_id = cs.id
79
                 WHERE cazr.id = $1
80
        `)
81
        getCommitmentWithMatchingTransferTokenQuery = sqlext.SimplifyWhitespace(`
82
                SELECT * FROM project_commitments_v2 WHERE id = $1 AND transfer_token = $2
83
        `)
84
        findCommitmentByTransferToken = sqlext.SimplifyWhitespace(`
85
                SELECT * FROM project_commitments_v2 WHERE transfer_token = $1
86
        `)
87
)
88

89
// GetProjectCommitments handles GET /v1/domains/:domain_id/projects/:project_id/commitments.
90
func (p *v1Provider) GetProjectCommitments(w http.ResponseWriter, r *http.Request) {
15✔
91
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments")
15✔
92
        token := p.CheckToken(r)
15✔
93
        if !token.Require(w, "project:show") {
16✔
94
                return
1✔
95
        }
1✔
96
        dbDomain := p.FindDomainFromRequest(w, r)
14✔
97
        if dbDomain == nil {
15✔
98
                return
1✔
99
        }
1✔
100
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
13✔
101
        if dbProject == nil {
14✔
102
                return
1✔
103
        }
1✔
104
        serviceInfos, err := p.Cluster.AllServiceInfos()
12✔
105
        if respondwith.ErrorText(w, err) {
12✔
106
                return
×
107
        }
×
108

109
        // enumerate project AZ resources
110
        filter := reports.ReadFilter(r, p.Cluster, serviceInfos)
12✔
111
        queryStr, joinArgs := filter.PrepareQuery(getClusterAZResourceLocationsQuery)
12✔
112
        whereStr, whereArgs := db.BuildSimpleWhereClause(map[string]any{"pazr.project_id": dbProject.ID}, len(joinArgs))
12✔
113
        azResourceLocationsByID := make(map[db.ClusterAZResourceID]core.AZResourceLocation)
12✔
114
        err = sqlext.ForeachRow(p.DB, fmt.Sprintf(queryStr, whereStr), append(joinArgs, whereArgs...), func(rows *sql.Rows) error {
137✔
115
                var (
125✔
116
                        id  db.ClusterAZResourceID
125✔
117
                        loc core.AZResourceLocation
125✔
118
                )
125✔
119
                err := rows.Scan(&id, &loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
125✔
120
                if err != nil {
125✔
121
                        return err
×
122
                }
×
123
                // this check is defense in depth (the DB should be consistent with our config)
124
                if core.HasResource(serviceInfos, loc.ServiceType, loc.ResourceName) {
250✔
125
                        azResourceLocationsByID[id] = loc
125✔
126
                }
125✔
127
                return nil
125✔
128
        })
129
        if respondwith.ErrorText(w, err) {
12✔
130
                return
×
131
        }
×
132

133
        // enumerate relevant project commitments
134
        queryStr, joinArgs = filter.PrepareQuery(getProjectCommitmentsQuery)
12✔
135
        whereStr, whereArgs = db.BuildSimpleWhereClause(map[string]any{"pc.project_id": dbProject.ID}, len(joinArgs))
12✔
136
        var dbCommitments []db.ProjectCommitmentV2
12✔
137
        _, err = p.DB.Select(&dbCommitments, fmt.Sprintf(queryStr, whereStr), append(joinArgs, whereArgs...)...)
12✔
138
        if respondwith.ErrorText(w, err) {
12✔
139
                return
×
140
        }
×
141

142
        // render response
143
        result := make([]limesresources.Commitment, 0, len(dbCommitments))
12✔
144
        for _, c := range dbCommitments {
26✔
145
                loc, exists := azResourceLocationsByID[c.AZResourceID]
14✔
146
                if !exists {
14✔
147
                        // defense in depth (the DB should not change that much between those two queries above)
×
148
                        continue
×
149
                }
150
                serviceInfo := core.InfoForService(serviceInfos, loc.ServiceType)
14✔
151
                resInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
14✔
152
                result = append(result, p.convertCommitmentToDisplayForm(c, loc, token, resInfo.Unit))
14✔
153
        }
154

155
        respondwith.JSON(w, http.StatusOK, map[string]any{"commitments": result})
12✔
156
}
157

158
func (p *v1Provider) convertCommitmentToDisplayForm(c db.ProjectCommitmentV2, loc core.AZResourceLocation, token *gopherpolicy.Token, unit limes.Unit) limesresources.Commitment {
59✔
159
        apiIdentity := p.Cluster.BehaviorForResource(loc.ServiceType, loc.ResourceName).IdentityInV1API
59✔
160
        return limesresources.Commitment{
59✔
161
                ID:               int64(c.ID),
59✔
162
                UUID:             string(c.UUID),
59✔
163
                ServiceType:      apiIdentity.ServiceType,
59✔
164
                ResourceName:     apiIdentity.Name,
59✔
165
                AvailabilityZone: loc.AvailabilityZone,
59✔
166
                Amount:           c.Amount,
59✔
167
                Unit:             unit,
59✔
168
                Duration:         c.Duration,
59✔
169
                CreatedAt:        limes.UnixEncodedTime{Time: c.CreatedAt},
59✔
170
                CreatorUUID:      c.CreatorUUID,
59✔
171
                CreatorName:      c.CreatorName,
59✔
172
                CanBeDeleted:     p.canDeleteCommitment(token, c),
59✔
173
                ConfirmBy:        options.Map(c.ConfirmBy, intoUnixEncodedTime).AsPointer(),
59✔
174
                ConfirmedAt:      options.Map(c.ConfirmedAt, intoUnixEncodedTime).AsPointer(),
59✔
175
                ExpiresAt:        limes.UnixEncodedTime{Time: c.ExpiresAt},
59✔
176
                TransferStatus:   c.TransferStatus,
59✔
177
                TransferToken:    c.TransferToken.AsPointer(),
59✔
178
                NotifyOnConfirm:  c.NotifyOnConfirm,
59✔
179
                WasRenewed:       c.RenewContextJSON.IsSome(),
59✔
180
        }
59✔
181
}
59✔
182

183
// parseAndValidateCommitmentRequest parses and validates the request body for a commitment creation or confirmation.
184
// This function in its current form should only be used if the serviceInfo is not necessary to be used outside
185
// of this validation to avoid unnecessary database queries.
186
func (p *v1Provider) parseAndValidateCommitmentRequest(w http.ResponseWriter, r *http.Request, dbDomain db.Domain) (*limesresources.CommitmentRequest, *core.AZResourceLocation, *core.ScopedCommitmentBehavior) {
46✔
187
        // parse request
46✔
188
        var parseTarget struct {
46✔
189
                Request limesresources.CommitmentRequest `json:"commitment"`
46✔
190
        }
46✔
191
        if !RequireJSON(w, r, &parseTarget) {
47✔
192
                return nil, nil, nil
1✔
193
        }
1✔
194
        req := parseTarget.Request
45✔
195

45✔
196
        // validate request
45✔
197
        serviceInfos, err := p.Cluster.AllServiceInfos()
45✔
198
        if respondwith.ErrorText(w, err) {
45✔
199
                return nil, nil, nil
×
200
        }
×
201
        nm := core.BuildResourceNameMapping(p.Cluster, serviceInfos)
45✔
202
        dbServiceType, dbResourceName, ok := nm.MapFromV1API(req.ServiceType, req.ResourceName)
45✔
203
        if !ok {
47✔
204
                msg := fmt.Sprintf("no such service and/or resource: %s/%s", req.ServiceType, req.ResourceName)
2✔
205
                http.Error(w, msg, http.StatusUnprocessableEntity)
2✔
206
                return nil, nil, nil
2✔
207
        }
2✔
208
        behavior := p.Cluster.CommitmentBehaviorForResource(dbServiceType, dbResourceName).ForDomain(dbDomain.Name)
43✔
209
        serviceInfo := core.InfoForService(serviceInfos, dbServiceType)
43✔
210
        resInfo := core.InfoForResource(serviceInfo, dbResourceName)
43✔
211
        if len(behavior.Durations) == 0 {
44✔
212
                http.Error(w, "commitments are not enabled for this resource", http.StatusUnprocessableEntity)
1✔
213
                return nil, nil, nil
1✔
214
        }
1✔
215
        if resInfo.Topology == liquid.FlatTopology {
45✔
216
                if req.AvailabilityZone != limes.AvailabilityZoneAny {
4✔
217
                        http.Error(w, `resource does not accept AZ-aware commitments, so the AZ must be set to "any"`, http.StatusUnprocessableEntity)
1✔
218
                        return nil, nil, nil
1✔
219
                }
1✔
220
        } else {
39✔
221
                if !slices.Contains(p.Cluster.Config.AvailabilityZones, req.AvailabilityZone) {
43✔
222
                        http.Error(w, "no such availability zone", http.StatusUnprocessableEntity)
4✔
223
                        return nil, nil, nil
4✔
224
                }
4✔
225
        }
226
        if !slices.Contains(behavior.Durations, req.Duration) {
38✔
227
                buf := must.Return(json.Marshal(behavior.Durations)) // panic on error is acceptable here, marshals should never fail
1✔
228
                msg := "unacceptable commitment duration for this resource, acceptable values: " + string(buf)
1✔
229
                http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
230
                return nil, nil, nil
1✔
231
        }
1✔
232
        if req.Amount == 0 {
37✔
233
                http.Error(w, "amount of committed resource must be greater than zero", http.StatusUnprocessableEntity)
1✔
234
                return nil, nil, nil
1✔
235
        }
1✔
236

237
        loc := core.AZResourceLocation{
35✔
238
                ServiceType:      dbServiceType,
35✔
239
                ResourceName:     dbResourceName,
35✔
240
                AvailabilityZone: req.AvailabilityZone,
35✔
241
        }
35✔
242
        return &req, &loc, &behavior
35✔
243
}
244

245
// CanConfirmNewProjectCommitment handles POST /v1/domains/:domain_id/projects/:project_id/commitments/can-confirm.
246
func (p *v1Provider) CanConfirmNewProjectCommitment(w http.ResponseWriter, r *http.Request) {
7✔
247
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/can-confirm")
7✔
248
        token := p.CheckToken(r)
7✔
249
        if !token.Require(w, "project:edit") {
7✔
250
                return
×
251
        }
×
252
        dbDomain := p.FindDomainFromRequest(w, r)
7✔
253
        if dbDomain == nil {
7✔
254
                return
×
255
        }
×
256
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
7✔
257
        if dbProject == nil {
7✔
258
                return
×
259
        }
×
260
        req, loc, behavior := p.parseAndValidateCommitmentRequest(w, r, *dbDomain)
7✔
261
        if req == nil {
7✔
262
                return
×
263
        }
×
264

265
        var (
7✔
266
                azResourceID              db.ClusterAZResourceID
7✔
267
                resourceAllowsCommitments bool
7✔
268
        )
7✔
269
        err := p.DB.QueryRow(findClusterAZResourceIDByLocationQuery, dbProject.ID, loc.ServiceType, loc.ResourceName, loc.AvailabilityZone).
7✔
270
                Scan(&azResourceID, &resourceAllowsCommitments)
7✔
271
        if respondwith.ErrorText(w, err) {
7✔
272
                return
×
273
        }
×
274
        if !resourceAllowsCommitments {
7✔
275
                msg := fmt.Sprintf("resource %s/%s is not enabled in this project", req.ServiceType, req.ResourceName)
×
276
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
277
                return
×
278
        }
×
279
        _ = azResourceID // returned by the above query, but not used in this function
7✔
280

7✔
281
        // commitments can never be confirmed immediately if we are before the min_confirm_date
7✔
282
        now := p.timeNow()
7✔
283
        if !behavior.CanConfirmCommitmentsAt(now) {
8✔
284
                respondwith.JSON(w, http.StatusOK, map[string]bool{"result": false})
1✔
285
                return
1✔
286
        }
1✔
287

288
        // check for committable capacity
289
        result, err := datamodel.CanConfirmNewCommitment(*loc, dbProject.ID, req.Amount, p.Cluster, p.DB)
6✔
290
        if respondwith.ErrorText(w, err) {
6✔
291
                return
×
292
        }
×
293
        respondwith.JSON(w, http.StatusOK, map[string]bool{"result": result})
6✔
294
}
295

296
// CreateProjectCommitment handles POST /v1/domains/:domain_id/projects/:project_id/commitments/new.
297
func (p *v1Provider) CreateProjectCommitment(w http.ResponseWriter, r *http.Request) {
42✔
298
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/new")
42✔
299
        token := p.CheckToken(r)
42✔
300
        if !token.Require(w, "project:edit") {
43✔
301
                return
1✔
302
        }
1✔
303
        dbDomain := p.FindDomainFromRequest(w, r)
41✔
304
        if dbDomain == nil {
42✔
305
                return
1✔
306
        }
1✔
307
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
40✔
308
        if dbProject == nil {
41✔
309
                return
1✔
310
        }
1✔
311
        req, loc, behavior := p.parseAndValidateCommitmentRequest(w, r, *dbDomain)
39✔
312
        if req == nil {
50✔
313
                return
11✔
314
        }
11✔
315

316
        var (
28✔
317
                azResourceID              db.ClusterAZResourceID
28✔
318
                resourceAllowsCommitments bool
28✔
319
        )
28✔
320
        err := p.DB.QueryRow(findClusterAZResourceIDByLocationQuery, dbProject.ID, loc.ServiceType, loc.ResourceName, loc.AvailabilityZone).
28✔
321
                Scan(&azResourceID, &resourceAllowsCommitments)
28✔
322
        if respondwith.ErrorText(w, err) {
28✔
323
                return
×
324
        }
×
325
        if !resourceAllowsCommitments {
29✔
326
                msg := fmt.Sprintf("resource %s/%s is not enabled in this project", req.ServiceType, req.ResourceName)
1✔
327
                http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
328
                return
1✔
329
        }
1✔
330

331
        // if given, confirm_by must definitely after time.Now(), and also after the MinConfirmDate if configured
332
        now := p.timeNow()
27✔
333
        if req.ConfirmBy != nil && req.ConfirmBy.Before(now) {
28✔
334
                http.Error(w, "confirm_by must not be set in the past", http.StatusUnprocessableEntity)
1✔
335
                return
1✔
336
        }
1✔
337
        if minConfirmBy, ok := behavior.MinConfirmDate.Unpack(); ok && minConfirmBy.After(now) {
31✔
338
                if req.ConfirmBy == nil || req.ConfirmBy.Before(minConfirmBy) {
6✔
339
                        msg := "this commitment needs a `confirm_by` timestamp at or after " + minConfirmBy.Format(time.RFC3339)
1✔
340
                        http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
341
                        return
1✔
342
                }
1✔
343
        }
344

345
        // we want to validate committable capacity in the same transaction that creates the commitment
346
        tx, err := p.DB.Begin()
25✔
347
        if respondwith.ErrorText(w, err) {
25✔
348
                return
×
349
        }
×
350
        defer sqlext.RollbackUnlessCommitted(tx)
25✔
351

25✔
352
        // prepare commitment
25✔
353
        confirmBy := options.Map(options.FromPointer(req.ConfirmBy), fromUnixEncodedTime)
25✔
354
        creationContext := db.CommitmentWorkflowContext{Reason: db.CommitmentReasonCreate}
25✔
355
        buf, err := json.Marshal(creationContext)
25✔
356
        if respondwith.ErrorText(w, err) {
25✔
357
                return
×
358
        }
×
359
        dbCommitment := db.ProjectCommitmentV2{
25✔
360
                UUID:                p.generateProjectCommitmentUUID(),
25✔
361
                AZResourceID:        azResourceID,
25✔
362
                ProjectID:           dbProject.ID,
25✔
363
                Amount:              req.Amount,
25✔
364
                Duration:            req.Duration,
25✔
365
                CreatedAt:           now,
25✔
366
                CreatorUUID:         token.UserUUID(),
25✔
367
                CreatorName:         fmt.Sprintf("%s@%s", token.UserName(), token.UserDomainName()),
25✔
368
                ConfirmBy:           confirmBy,
25✔
369
                ConfirmedAt:         None[time.Time](), // may be set below
25✔
370
                ExpiresAt:           req.Duration.AddTo(confirmBy.UnwrapOr(now)),
25✔
371
                CreationContextJSON: json.RawMessage(buf),
25✔
372
        }
25✔
373
        if req.NotifyOnConfirm && req.ConfirmBy == nil {
26✔
374
                http.Error(w, "notification on confirm cannot be set for commitments with immediate confirmation", http.StatusConflict)
1✔
375
                return
1✔
376
        }
1✔
377
        dbCommitment.NotifyOnConfirm = req.NotifyOnConfirm
24✔
378

24✔
379
        if req.ConfirmBy == nil {
42✔
380
                // if not planned for confirmation in the future, confirm immediately (or fail)
18✔
381
                ok, err := datamodel.CanConfirmNewCommitment(*loc, dbProject.ID, req.Amount, p.Cluster, tx)
18✔
382
                if respondwith.ErrorText(w, err) {
18✔
383
                        return
×
384
                }
×
385
                if !ok {
18✔
386
                        http.Error(w, "not enough capacity available for immediate confirmation", http.StatusConflict)
×
387
                        return
×
388
                }
×
389
                dbCommitment.ConfirmedAt = Some(now)
18✔
390
                dbCommitment.State = db.CommitmentStateActive
18✔
391
        } else {
6✔
392
                dbCommitment.State = db.CommitmentStatePlanned
6✔
393
        }
6✔
394

395
        // create commitment
396
        err = tx.Insert(&dbCommitment)
24✔
397
        if respondwith.ErrorText(w, err) {
24✔
398
                return
×
399
        }
×
400
        err = tx.Commit()
24✔
401
        if respondwith.ErrorText(w, err) {
24✔
402
                return
×
403
        }
×
404
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
24✔
405
        if respondwith.ErrorText(w, err) {
24✔
406
                return
×
407
        }
×
408
        serviceInfo, ok := maybeServiceInfo.Unpack()
24✔
409
        if !ok {
24✔
410
                http.Error(w, "service not found", http.StatusNotFound)
×
411
                return
×
412
        }
×
413
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
24✔
414
        commitment := p.convertCommitmentToDisplayForm(dbCommitment, *loc, token, resourceInfo.Unit)
24✔
415
        p.auditor.Record(audittools.Event{
24✔
416
                Time:       now,
24✔
417
                Request:    r,
24✔
418
                User:       token,
24✔
419
                ReasonCode: http.StatusCreated,
24✔
420
                Action:     cadf.CreateAction,
24✔
421
                Target: commitmentEventTarget{
24✔
422
                        DomainID:        dbDomain.UUID,
24✔
423
                        DomainName:      dbDomain.Name,
24✔
424
                        ProjectID:       dbProject.UUID,
24✔
425
                        ProjectName:     dbProject.Name,
24✔
426
                        Commitments:     []limesresources.Commitment{commitment},
24✔
427
                        WorkflowContext: Some(creationContext),
24✔
428
                },
24✔
429
        })
24✔
430

24✔
431
        // if the commitment is immediately confirmed, trigger a capacity scrape in
24✔
432
        // order to ApplyComputedProjectQuotas based on the new commitment
24✔
433
        if dbCommitment.ConfirmedAt.IsSome() {
42✔
434
                _, err := p.DB.Exec(`UPDATE cluster_services SET next_scrape_at = $1 WHERE type = $2`, now, loc.ServiceType)
18✔
435
                if respondwith.ErrorText(w, err) {
18✔
436
                        return
×
437
                }
×
438
        }
439

440
        // display the possibly confirmed commitment to the user
441
        err = p.DB.SelectOne(&dbCommitment, `SELECT * FROM project_commitments_v2 WHERE id = $1`, dbCommitment.ID)
24✔
442
        if respondwith.ErrorText(w, err) {
24✔
443
                return
×
444
        }
×
445

446
        respondwith.JSON(w, http.StatusCreated, map[string]any{"commitment": commitment})
24✔
447
}
448

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

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

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

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

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

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

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

545
        // Fill workflow context
546
        creationContext := db.CommitmentWorkflowContext{
1✔
547
                Reason:                 db.CommitmentReasonMerge,
1✔
548
                RelatedCommitmentIDs:   commitmentIDs,
1✔
549
                RelatedCommitmentUUIDs: commitmentUUIDs,
1✔
550
        }
1✔
551
        buf, err := json.Marshal(creationContext)
1✔
552
        if respondwith.ErrorText(w, err) {
1✔
553
                return
×
554
        }
×
555
        dbMergedCommitment.CreationContextJSON = json.RawMessage(buf)
1✔
556

1✔
557
        // Insert into database
1✔
558
        err = tx.Insert(&dbMergedCommitment)
1✔
559
        if respondwith.ErrorText(w, err) {
1✔
560
                return
×
561
        }
×
562

563
        // Mark merged commits as superseded
564
        supersedeContext := db.CommitmentWorkflowContext{
1✔
565
                Reason:                 db.CommitmentReasonMerge,
1✔
566
                RelatedCommitmentIDs:   []db.ProjectCommitmentID{dbMergedCommitment.ID},
1✔
567
                RelatedCommitmentUUIDs: []db.ProjectCommitmentUUID{dbMergedCommitment.UUID},
1✔
568
        }
1✔
569
        buf, err = json.Marshal(supersedeContext)
1✔
570
        if respondwith.ErrorText(w, err) {
1✔
571
                return
×
572
        }
×
573
        for _, dbCommitment := range dbCommitments {
3✔
574
                dbCommitment.SupersededAt = Some(now)
2✔
575
                dbCommitment.SupersedeContextJSON = Some(json.RawMessage(buf))
2✔
576
                dbCommitment.State = db.CommitmentStateSuperseded
2✔
577
                _, err = tx.Update(&dbCommitment)
2✔
578
                if respondwith.ErrorText(w, err) {
2✔
579
                        return
×
580
                }
×
581
        }
582

583
        err = tx.Commit()
1✔
584
        if respondwith.ErrorText(w, err) {
1✔
585
                return
×
586
        }
×
587

588
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
1✔
589
        if respondwith.ErrorText(w, err) {
1✔
590
                return
×
591
        }
×
592
        serviceInfo, ok := maybeServiceInfo.Unpack()
1✔
593
        if !ok {
1✔
594
                http.Error(w, "service not found", http.StatusNotFound)
×
595
                return
×
596
        }
×
597
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
1✔
598

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

1✔
617
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
1✔
618
}
619

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

623
// RenewProjectCommitments handles POST /v1/domains/:domain_id/projects/:project_id/commitments/:id/renew.
624
func (p *v1Provider) RenewProjectCommitments(w http.ResponseWriter, r *http.Request) {
6✔
625
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/:id/renew")
6✔
626
        token := p.CheckToken(r)
6✔
627
        if !token.Require(w, "project:edit") {
6✔
628
                return
×
629
        }
×
630
        dbDomain := p.FindDomainFromRequest(w, r)
6✔
631
        if dbDomain == nil {
6✔
632
                return
×
633
        }
×
634
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
6✔
635
        if dbProject == nil {
6✔
636
                return
×
637
        }
×
638

639
        // Load commitment
640
        var dbCommitment db.ProjectCommitmentV2
6✔
641
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, mux.Vars(r)["id"], dbProject.ID)
6✔
642
        if errors.Is(err, sql.ErrNoRows) {
6✔
643
                http.Error(w, "no such commitment", http.StatusNotFound)
×
644
                return
×
645
        } else if respondwith.ErrorText(w, err) {
6✔
646
                return
×
647
        }
×
648
        now := p.timeNow()
6✔
649

6✔
650
        // Check if commitment can be renewed
6✔
651
        var errs errext.ErrorSet
6✔
652
        if dbCommitment.State != db.CommitmentStateActive {
7✔
653
                errs.Addf("invalid state %q", dbCommitment.State)
1✔
654
        } else if now.After(dbCommitment.ExpiresAt) {
7✔
655
                errs.Addf("invalid state %q", db.CommitmentStateExpired)
1✔
656
        }
1✔
657
        if now.Before(dbCommitment.ExpiresAt.Add(-commitmentRenewalPeriod)) {
7✔
658
                errs.Addf("renewal attempt too early")
1✔
659
        }
1✔
660
        if dbCommitment.RenewContextJSON.IsSome() {
7✔
661
                errs.Addf("already renewed")
1✔
662
        }
1✔
663

664
        if !errs.IsEmpty() {
10✔
665
                msg := "cannot renew this commitment: " + errs.Join(", ")
4✔
666
                http.Error(w, msg, http.StatusConflict)
4✔
667
                return
4✔
668
        }
4✔
669

670
        // Create renewed commitment
671
        tx, err := p.DB.Begin()
2✔
672
        if respondwith.ErrorText(w, err) {
2✔
673
                return
×
674
        }
×
675
        defer sqlext.RollbackUnlessCommitted(tx)
2✔
676

2✔
677
        var loc core.AZResourceLocation
2✔
678
        err = p.DB.QueryRow(findClusterAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
2✔
679
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
2✔
680
        if errors.Is(err, sql.ErrNoRows) {
2✔
681
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
682
                return
×
683
        } else if respondwith.ErrorText(w, err) {
2✔
684
                return
×
685
        }
×
686

687
        creationContext := db.CommitmentWorkflowContext{
2✔
688
                Reason:                 db.CommitmentReasonRenew,
2✔
689
                RelatedCommitmentIDs:   []db.ProjectCommitmentID{dbCommitment.ID},
2✔
690
                RelatedCommitmentUUIDs: []db.ProjectCommitmentUUID{dbCommitment.UUID},
2✔
691
        }
2✔
692
        buf, err := json.Marshal(creationContext)
2✔
693
        if respondwith.ErrorText(w, err) {
2✔
694
                return
×
695
        }
×
696
        dbRenewedCommitment := db.ProjectCommitmentV2{
2✔
697
                UUID:                p.generateProjectCommitmentUUID(),
2✔
698
                ProjectID:           dbProject.ID,
2✔
699
                AZResourceID:        dbCommitment.AZResourceID,
2✔
700
                Amount:              dbCommitment.Amount,
2✔
701
                Duration:            dbCommitment.Duration,
2✔
702
                CreatedAt:           now,
2✔
703
                CreatorUUID:         token.UserUUID(),
2✔
704
                CreatorName:         fmt.Sprintf("%s@%s", token.UserName(), token.UserDomainName()),
2✔
705
                ConfirmBy:           Some(dbCommitment.ExpiresAt),
2✔
706
                ExpiresAt:           dbCommitment.Duration.AddTo(dbCommitment.ExpiresAt),
2✔
707
                State:               db.CommitmentStatePlanned,
2✔
708
                CreationContextJSON: json.RawMessage(buf),
2✔
709
        }
2✔
710

2✔
711
        err = tx.Insert(&dbRenewedCommitment)
2✔
712
        if respondwith.ErrorText(w, err) {
2✔
713
                return
×
714
        }
×
715

716
        renewContext := db.CommitmentWorkflowContext{
2✔
717
                Reason:                 db.CommitmentReasonRenew,
2✔
718
                RelatedCommitmentIDs:   []db.ProjectCommitmentID{dbRenewedCommitment.ID},
2✔
719
                RelatedCommitmentUUIDs: []db.ProjectCommitmentUUID{dbRenewedCommitment.UUID},
2✔
720
        }
2✔
721
        buf, err = json.Marshal(renewContext)
2✔
722
        if respondwith.ErrorText(w, err) {
2✔
723
                return
×
724
        }
×
725
        dbCommitment.RenewContextJSON = Some(json.RawMessage(buf))
2✔
726
        _, err = tx.Update(&dbCommitment)
2✔
727
        if respondwith.ErrorText(w, err) {
2✔
728
                return
×
729
        }
×
730

731
        err = tx.Commit()
2✔
732
        if respondwith.ErrorText(w, err) {
2✔
733
                return
×
734
        }
×
735

736
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
2✔
737
        if respondwith.ErrorText(w, err) {
2✔
738
                return
×
739
        }
×
740
        serviceInfo, ok := maybeServiceInfo.Unpack()
2✔
741
        if !ok {
2✔
742
                http.Error(w, "service not found", http.StatusNotFound)
×
743
                return
×
744
        }
×
745
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
2✔
746

2✔
747
        // Create resultset and auditlogs
2✔
748
        c := p.convertCommitmentToDisplayForm(dbRenewedCommitment, loc, token, resourceInfo.Unit)
2✔
749
        auditEvent := commitmentEventTarget{
2✔
750
                DomainID:        dbDomain.UUID,
2✔
751
                DomainName:      dbDomain.Name,
2✔
752
                ProjectID:       dbProject.UUID,
2✔
753
                ProjectName:     dbProject.Name,
2✔
754
                Commitments:     []limesresources.Commitment{c},
2✔
755
                WorkflowContext: Some(creationContext),
2✔
756
        }
2✔
757

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

2✔
767
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
2✔
768
}
769

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

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

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

812
        // perform deletion
813
        _, err = p.DB.Delete(&dbCommitment)
4✔
814
        if respondwith.ErrorText(w, err) {
4✔
815
                return
×
816
        }
×
817
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
4✔
818
        if respondwith.ErrorText(w, err) {
4✔
819
                return
×
820
        }
×
821
        serviceInfo, ok := maybeServiceInfo.Unpack()
4✔
822
        if !ok {
4✔
823
                http.Error(w, "service not found", http.StatusNotFound)
×
824
                return
×
825
        }
×
826
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
4✔
827
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token, resourceInfo.Unit)
4✔
828
        p.auditor.Record(audittools.Event{
4✔
829
                Time:       p.timeNow(),
4✔
830
                Request:    r,
4✔
831
                User:       token,
4✔
832
                ReasonCode: http.StatusNoContent,
4✔
833
                Action:     cadf.DeleteAction,
4✔
834
                Target: commitmentEventTarget{
4✔
835
                        DomainID:    dbDomain.UUID,
4✔
836
                        DomainName:  dbDomain.Name,
4✔
837
                        ProjectID:   dbProject.UUID,
4✔
838
                        ProjectName: dbProject.Name,
4✔
839
                        Commitments: []limesresources.Commitment{c},
4✔
840
                },
4✔
841
        })
4✔
842

4✔
843
        w.WriteHeader(http.StatusNoContent)
4✔
844
}
845

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

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

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

8✔
892
        if req.TransferStatus != limesresources.CommitmentTransferStatusUnlisted && req.TransferStatus != limesresources.CommitmentTransferStatusPublic {
8✔
893
                http.Error(w, fmt.Sprintf("Invalid transfer_status code. Must be %s or %s.", limesresources.CommitmentTransferStatusUnlisted, limesresources.CommitmentTransferStatusPublic), http.StatusBadRequest)
×
894
                return
×
895
        }
×
896

897
        if req.Amount <= 0 {
9✔
898
                http.Error(w, "delivered amount needs to be a positive value.", http.StatusBadRequest)
1✔
899
                return
1✔
900
        }
1✔
901

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

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

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

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

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

989
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
6✔
990
        if respondwith.ErrorText(w, err) {
6✔
991
                return
×
992
        }
×
993
        serviceInfo, ok := maybeServiceInfo.Unpack()
6✔
994
        if !ok {
6✔
995
                http.Error(w, "service not found", http.StatusNotFound)
×
996
                return
×
997
        }
×
998
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
6✔
999
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token, resourceInfo.Unit)
6✔
1000
        if respondwith.ErrorText(w, err) {
6✔
1001
                return
×
1002
        }
×
1003
        p.auditor.Record(audittools.Event{
6✔
1004
                Time:       p.timeNow(),
6✔
1005
                Request:    r,
6✔
1006
                User:       token,
6✔
1007
                ReasonCode: http.StatusAccepted,
6✔
1008
                Action:     cadf.UpdateAction,
6✔
1009
                Target: commitmentEventTarget{
6✔
1010
                        DomainID:    dbDomain.UUID,
6✔
1011
                        DomainName:  dbDomain.Name,
6✔
1012
                        ProjectID:   dbProject.UUID,
6✔
1013
                        ProjectName: dbProject.Name,
6✔
1014
                        Commitments: []limesresources.Commitment{c},
6✔
1015
                },
6✔
1016
        })
6✔
1017
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
6✔
1018
}
1019

1020
func (p *v1Provider) buildSplitCommitment(dbCommitment db.ProjectCommitmentV2, amount uint64) (db.ProjectCommitmentV2, error) {
5✔
1021
        now := p.timeNow()
5✔
1022
        creationContext := db.CommitmentWorkflowContext{
5✔
1023
                Reason:                 db.CommitmentReasonSplit,
5✔
1024
                RelatedCommitmentIDs:   []db.ProjectCommitmentID{dbCommitment.ID},
5✔
1025
                RelatedCommitmentUUIDs: []db.ProjectCommitmentUUID{dbCommitment.UUID},
5✔
1026
        }
5✔
1027
        buf, err := json.Marshal(creationContext)
5✔
1028
        if err != nil {
5✔
NEW
1029
                return db.ProjectCommitmentV2{}, err
×
1030
        }
×
1031
        return db.ProjectCommitmentV2{
5✔
1032
                UUID:                p.generateProjectCommitmentUUID(),
5✔
1033
                ProjectID:           dbCommitment.ProjectID,
5✔
1034
                AZResourceID:        dbCommitment.AZResourceID,
5✔
1035
                Amount:              amount,
5✔
1036
                Duration:            dbCommitment.Duration,
5✔
1037
                CreatedAt:           now,
5✔
1038
                CreatorUUID:         dbCommitment.CreatorUUID,
5✔
1039
                CreatorName:         dbCommitment.CreatorName,
5✔
1040
                ConfirmBy:           dbCommitment.ConfirmBy,
5✔
1041
                ConfirmedAt:         dbCommitment.ConfirmedAt,
5✔
1042
                ExpiresAt:           dbCommitment.ExpiresAt,
5✔
1043
                CreationContextJSON: json.RawMessage(buf),
5✔
1044
                State:               dbCommitment.State,
5✔
1045
        }, nil
5✔
1046
}
1047

1048
func (p *v1Provider) buildConvertedCommitment(dbCommitment db.ProjectCommitmentV2, azResourceID db.ClusterAZResourceID, amount uint64) (db.ProjectCommitmentV2, error) {
2✔
1049
        now := p.timeNow()
2✔
1050
        creationContext := db.CommitmentWorkflowContext{
2✔
1051
                Reason:                 db.CommitmentReasonConvert,
2✔
1052
                RelatedCommitmentIDs:   []db.ProjectCommitmentID{dbCommitment.ID},
2✔
1053
                RelatedCommitmentUUIDs: []db.ProjectCommitmentUUID{dbCommitment.UUID},
2✔
1054
        }
2✔
1055
        buf, err := json.Marshal(creationContext)
2✔
1056
        if err != nil {
2✔
NEW
1057
                return db.ProjectCommitmentV2{}, err
×
1058
        }
×
1059
        return db.ProjectCommitmentV2{
2✔
1060
                UUID:                p.generateProjectCommitmentUUID(),
2✔
1061
                ProjectID:           dbCommitment.ProjectID,
2✔
1062
                AZResourceID:        azResourceID,
2✔
1063
                Amount:              amount,
2✔
1064
                Duration:            dbCommitment.Duration,
2✔
1065
                CreatedAt:           now,
2✔
1066
                CreatorUUID:         dbCommitment.CreatorUUID,
2✔
1067
                CreatorName:         dbCommitment.CreatorName,
2✔
1068
                ConfirmBy:           dbCommitment.ConfirmBy,
2✔
1069
                ConfirmedAt:         dbCommitment.ConfirmedAt,
2✔
1070
                ExpiresAt:           dbCommitment.ExpiresAt,
2✔
1071
                CreationContextJSON: json.RawMessage(buf),
2✔
1072
                State:               dbCommitment.State,
2✔
1073
        }, nil
2✔
1074
}
1075

1076
// GetCommitmentByTransferToken handles GET /v1/commitments/{token}
1077
func (p *v1Provider) GetCommitmentByTransferToken(w http.ResponseWriter, r *http.Request) {
2✔
1078
        httpapi.IdentifyEndpoint(r, "/v1/commitments/:token")
2✔
1079
        token := p.CheckToken(r)
2✔
1080
        if !token.Require(w, "cluster:show_basic") {
2✔
1081
                return
×
1082
        }
×
1083
        transferToken := mux.Vars(r)["token"]
2✔
1084

2✔
1085
        // The token column is a unique key, so we expect only one result.
2✔
1086
        var dbCommitment db.ProjectCommitmentV2
2✔
1087
        err := p.DB.SelectOne(&dbCommitment, findCommitmentByTransferToken, transferToken)
2✔
1088
        if errors.Is(err, sql.ErrNoRows) {
3✔
1089
                http.Error(w, "no matching commitment found.", http.StatusNotFound)
1✔
1090
                return
1✔
1091
        } else if respondwith.ErrorText(w, err) {
2✔
1092
                return
×
1093
        }
×
1094

1095
        var loc core.AZResourceLocation
1✔
1096
        err = p.DB.QueryRow(findClusterAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
1✔
1097
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
1✔
1098
        if errors.Is(err, sql.ErrNoRows) {
1✔
1099
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
1100
                http.Error(w, "location data not found.", http.StatusNotFound)
×
1101
                return
×
1102
        } else if respondwith.ErrorText(w, err) {
1✔
1103
                return
×
1104
        }
×
1105

1106
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
1✔
1107
        if respondwith.ErrorText(w, err) {
1✔
1108
                return
×
1109
        }
×
1110
        serviceInfo, ok := maybeServiceInfo.Unpack()
1✔
1111
        if !ok {
1✔
1112
                http.Error(w, "service not found", http.StatusNotFound)
×
1113
                return
×
1114
        }
×
1115
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
1✔
1116
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token, resourceInfo.Unit)
1✔
1117
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
1✔
1118
}
1119

1120
// TransferCommitment handles POST /v1/domains/{domain_id}/projects/{project_id}/transfer-commitment/{id}?token={token}
1121
func (p *v1Provider) TransferCommitment(w http.ResponseWriter, r *http.Request) {
5✔
1122
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/transfer-commitment/:id")
5✔
1123
        token := p.CheckToken(r)
5✔
1124
        if !token.Require(w, "project:edit") {
5✔
1125
                return
×
1126
        }
×
1127
        transferToken := r.Header.Get("Transfer-Token")
5✔
1128
        if transferToken == "" {
6✔
1129
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
1✔
1130
                return
1✔
1131
        }
1✔
1132
        commitmentID := mux.Vars(r)["id"]
4✔
1133
        if commitmentID == "" {
4✔
1134
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
×
1135
                return
×
1136
        }
×
1137
        dbDomain := p.FindDomainFromRequest(w, r)
4✔
1138
        if dbDomain == nil {
4✔
1139
                return
×
1140
        }
×
1141
        targetProject := p.FindProjectFromRequest(w, r, dbDomain)
4✔
1142
        if targetProject == nil {
4✔
1143
                return
×
1144
        }
×
1145

1146
        // find commitment by transfer_token
1147
        var dbCommitment db.ProjectCommitmentV2
4✔
1148
        err := p.DB.SelectOne(&dbCommitment, getCommitmentWithMatchingTransferTokenQuery, commitmentID, transferToken)
4✔
1149
        if errors.Is(err, sql.ErrNoRows) {
5✔
1150
                http.Error(w, "no matching commitment found", http.StatusNotFound)
1✔
1151
                return
1✔
1152
        } else if respondwith.ErrorText(w, err) {
4✔
1153
                return
×
1154
        }
×
1155

1156
        var loc core.AZResourceLocation
3✔
1157
        err = p.DB.QueryRow(findClusterAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
3✔
1158
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
3✔
1159
        if errors.Is(err, sql.ErrNoRows) {
3✔
1160
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
1161
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
1162
                return
×
1163
        } else if respondwith.ErrorText(w, err) {
3✔
1164
                return
×
1165
        }
×
1166

1167
        // check that the target project allows commitments at all
1168
        var (
3✔
1169
                azResourceID              db.ClusterAZResourceID
3✔
1170
                resourceAllowsCommitments bool
3✔
1171
        )
3✔
1172
        err = p.DB.QueryRow(findClusterAZResourceIDByLocationQuery, targetProject.ID, loc.ServiceType, loc.ResourceName, loc.AvailabilityZone).
3✔
1173
                Scan(&azResourceID, &resourceAllowsCommitments)
3✔
1174
        if respondwith.ErrorText(w, err) {
3✔
1175
                return
×
1176
        }
×
1177
        if !resourceAllowsCommitments {
3✔
NEW
1178
                msg := fmt.Sprintf("resource %s/%s is not enabled in the target project", loc.ServiceType, loc.ResourceName)
×
NEW
1179
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
NEW
1180
                return
×
NEW
1181
        }
×
1182
        _ = azResourceID // returned by the above query, but not used in this function
3✔
1183

3✔
1184
        // validate that we have enough committable capacity on the receiving side
3✔
1185
        tx, err := p.DB.Begin()
3✔
1186
        if respondwith.ErrorText(w, err) {
3✔
1187
                return
×
1188
        }
×
1189
        defer sqlext.RollbackUnlessCommitted(tx)
3✔
1190
        ok, err := datamodel.CanMoveExistingCommitment(dbCommitment.Amount, loc, dbCommitment.ProjectID, targetProject.ID, p.Cluster, tx)
3✔
1191
        if respondwith.ErrorText(w, err) {
3✔
1192
                return
×
1193
        }
×
1194
        if !ok {
4✔
1195
                http.Error(w, "not enough committable capacity on the receiving side", http.StatusConflict)
1✔
1196
                return
1✔
1197
        }
1✔
1198

1199
        dbCommitment.TransferStatus = ""
2✔
1200
        dbCommitment.TransferToken = None[string]()
2✔
1201
        dbCommitment.ProjectID = targetProject.ID
2✔
1202
        _, err = tx.Update(&dbCommitment)
2✔
1203
        if respondwith.ErrorText(w, err) {
2✔
1204
                return
×
1205
        }
×
1206
        err = tx.Commit()
2✔
1207
        if respondwith.ErrorText(w, err) {
2✔
1208
                return
×
1209
        }
×
1210

1211
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
2✔
1212
        if respondwith.ErrorText(w, err) {
2✔
1213
                return
×
1214
        }
×
1215
        serviceInfo, ok := maybeServiceInfo.Unpack()
2✔
1216
        if !ok {
2✔
1217
                http.Error(w, "service not found", http.StatusNotFound)
×
1218
                return
×
1219
        }
×
1220
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
2✔
1221
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token, resourceInfo.Unit)
2✔
1222
        p.auditor.Record(audittools.Event{
2✔
1223
                Time:       p.timeNow(),
2✔
1224
                Request:    r,
2✔
1225
                User:       token,
2✔
1226
                ReasonCode: http.StatusAccepted,
2✔
1227
                Action:     cadf.UpdateAction,
2✔
1228
                Target: commitmentEventTarget{
2✔
1229
                        DomainID:    dbDomain.UUID,
2✔
1230
                        DomainName:  dbDomain.Name,
2✔
1231
                        ProjectID:   targetProject.UUID,
2✔
1232
                        ProjectName: targetProject.Name,
2✔
1233
                        Commitments: []limesresources.Commitment{c},
2✔
1234
                },
2✔
1235
        })
2✔
1236

2✔
1237
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
2✔
1238
}
1239

1240
// GetCommitmentConversion handles GET /v1/commitment-conversion/{service_type}/{resource_name}
1241
func (p *v1Provider) GetCommitmentConversions(w http.ResponseWriter, r *http.Request) {
2✔
1242
        httpapi.IdentifyEndpoint(r, "/v1/commitment-conversion/:service_type/:resource_name")
2✔
1243
        token := p.CheckToken(r)
2✔
1244
        if !token.Require(w, "cluster:show_basic") {
2✔
1245
                return
×
1246
        }
×
1247

1248
        // TODO v2 API: This endpoint should be project-scoped in order to make it
1249
        // easier to select the correct domain scope for the CommitmentBehavior.
1250
        forTokenScope := func(behavior core.CommitmentBehavior) core.ScopedCommitmentBehavior {
13✔
1251
                name := cmp.Or(token.ProjectScopeDomainName(), token.DomainScopeName(), "")
11✔
1252
                if name != "" {
22✔
1253
                        return behavior.ForDomain(name)
11✔
1254
                }
11✔
1255
                return behavior.ForCluster()
×
1256
        }
1257

1258
        // validate request
1259
        vars := mux.Vars(r)
2✔
1260
        serviceInfos, err := p.Cluster.AllServiceInfos()
2✔
1261
        if respondwith.ErrorText(w, err) {
2✔
1262
                return
×
1263
        }
×
1264

1265
        nm := core.BuildResourceNameMapping(p.Cluster, serviceInfos)
2✔
1266
        sourceServiceType, sourceResourceName, exists := nm.MapFromV1API(
2✔
1267
                limes.ServiceType(vars["service_type"]),
2✔
1268
                limesresources.ResourceName(vars["resource_name"]),
2✔
1269
        )
2✔
1270
        if !exists {
2✔
1271
                msg := fmt.Sprintf("no such service and/or resource: %s/%s", vars["service_type"], vars["resource_name"])
×
1272
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
1273
                return
×
1274
        }
×
1275
        sourceBehavior := forTokenScope(p.Cluster.CommitmentBehaviorForResource(sourceServiceType, sourceResourceName))
2✔
1276

2✔
1277
        serviceInfo := core.InfoForService(serviceInfos, sourceServiceType)
2✔
1278
        sourceResInfo := core.InfoForResource(serviceInfo, sourceResourceName)
2✔
1279

2✔
1280
        // enumerate possible conversions
2✔
1281
        conversions := make([]limesresources.CommitmentConversionRule, 0)
2✔
1282
        if sourceBehavior.ConversionRule.IsSome() {
4✔
1283
                for _, targetServiceType := range slices.Sorted(maps.Keys(serviceInfos)) {
8✔
1284
                        for targetResourceName, targetResInfo := range serviceInfos[targetServiceType].Resources {
28✔
1285
                                if sourceServiceType == targetServiceType && sourceResourceName == targetResourceName {
24✔
1286
                                        continue
2✔
1287
                                }
1288
                                if sourceResInfo.Unit != targetResInfo.Unit {
31✔
1289
                                        continue
11✔
1290
                                }
1291

1292
                                targetBehavior := forTokenScope(p.Cluster.CommitmentBehaviorForResource(targetServiceType, targetResourceName))
9✔
1293
                                if rate, ok := sourceBehavior.GetConversionRateTo(targetBehavior).Unpack(); ok {
13✔
1294
                                        apiServiceType, apiResourceName, ok := nm.MapToV1API(targetServiceType, targetResourceName)
4✔
1295
                                        if ok {
8✔
1296
                                                conversions = append(conversions, limesresources.CommitmentConversionRule{
4✔
1297
                                                        FromAmount:     rate.FromAmount,
4✔
1298
                                                        ToAmount:       rate.ToAmount,
4✔
1299
                                                        TargetService:  apiServiceType,
4✔
1300
                                                        TargetResource: apiResourceName,
4✔
1301
                                                })
4✔
1302
                                        }
4✔
1303
                                }
1304
                        }
1305
                }
1306
        }
1307

1308
        // use a defined sorting to ensure deterministic behavior in tests
1309
        slices.SortFunc(conversions, func(lhs, rhs limesresources.CommitmentConversionRule) int {
5✔
1310
                result := strings.Compare(string(lhs.TargetService), string(rhs.TargetService))
3✔
1311
                if result != 0 {
5✔
1312
                        return result
2✔
1313
                }
2✔
1314
                return strings.Compare(string(lhs.TargetResource), string(rhs.TargetResource))
1✔
1315
        })
1316

1317
        respondwith.JSON(w, http.StatusOK, map[string]any{"conversions": conversions})
2✔
1318
}
1319

1320
// ConvertCommitment handles POST /v1/domains/{domain_id}/projects/{project_id}/commitments/{commitment_id}/convert
1321
func (p *v1Provider) ConvertCommitment(w http.ResponseWriter, r *http.Request) {
9✔
1322
        httpapi.IdentifyEndpoint(r, "/v1/domains/:domain_id/projects/:project_id/commitments/:commitment_id/convert")
9✔
1323
        token := p.CheckToken(r)
9✔
1324
        if !token.Require(w, "project:edit") {
9✔
1325
                return
×
1326
        }
×
1327
        commitmentID := mux.Vars(r)["commitment_id"]
9✔
1328
        if commitmentID == "" {
9✔
1329
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
×
1330
                return
×
1331
        }
×
1332
        dbDomain := p.FindDomainFromRequest(w, r)
9✔
1333
        if dbDomain == nil {
9✔
1334
                return
×
1335
        }
×
1336
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
9✔
1337
        if dbProject == nil {
9✔
1338
                return
×
1339
        }
×
1340

1341
        // section: sourceBehavior
1342
        var dbCommitment db.ProjectCommitmentV2
9✔
1343
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, commitmentID, dbProject.ID)
9✔
1344
        if errors.Is(err, sql.ErrNoRows) {
10✔
1345
                http.Error(w, "no such commitment", http.StatusNotFound)
1✔
1346
                return
1✔
1347
        } else if respondwith.ErrorText(w, err) {
9✔
1348
                return
×
1349
        }
×
1350
        var sourceLoc core.AZResourceLocation
8✔
1351
        err = p.DB.QueryRow(findClusterAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
8✔
1352
                Scan(&sourceLoc.ServiceType, &sourceLoc.ResourceName, &sourceLoc.AvailabilityZone)
8✔
1353
        if errors.Is(err, sql.ErrNoRows) {
8✔
1354
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
1355
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
1356
                return
×
1357
        } else if respondwith.ErrorText(w, err) {
8✔
1358
                return
×
1359
        }
×
1360
        sourceBehavior := p.Cluster.CommitmentBehaviorForResource(sourceLoc.ServiceType, sourceLoc.ResourceName).ForDomain(dbDomain.Name)
8✔
1361

8✔
1362
        // section: targetBehavior
8✔
1363
        var parseTarget struct {
8✔
1364
                Request struct {
8✔
1365
                        TargetService  limes.ServiceType           `json:"target_service"`
8✔
1366
                        TargetResource limesresources.ResourceName `json:"target_resource"`
8✔
1367
                        SourceAmount   uint64                      `json:"source_amount"`
8✔
1368
                        TargetAmount   uint64                      `json:"target_amount"`
8✔
1369
                } `json:"commitment"`
8✔
1370
        }
8✔
1371
        if !RequireJSON(w, r, &parseTarget) {
8✔
1372
                return
×
1373
        }
×
1374
        req := parseTarget.Request
8✔
1375
        serviceInfos, err := p.Cluster.AllServiceInfos()
8✔
1376
        if respondwith.ErrorText(w, err) {
8✔
1377
                return
×
1378
        }
×
1379
        nm := core.BuildResourceNameMapping(p.Cluster, serviceInfos)
8✔
1380
        targetServiceType, targetResourceName, exists := nm.MapFromV1API(req.TargetService, req.TargetResource)
8✔
1381
        if !exists {
8✔
1382
                msg := fmt.Sprintf("no such service and/or resource: %s/%s", req.TargetService, req.TargetResource)
×
1383
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
1384
                return
×
1385
        }
×
1386
        targetBehavior := p.Cluster.CommitmentBehaviorForResource(targetServiceType, targetResourceName).ForDomain(dbDomain.Name)
8✔
1387
        if sourceLoc.ResourceName == targetResourceName && sourceLoc.ServiceType == targetServiceType {
9✔
1388
                http.Error(w, "conversion attempt to the same resource.", http.StatusConflict)
1✔
1389
                return
1✔
1390
        }
1✔
1391
        if len(targetBehavior.Durations) == 0 {
7✔
1392
                msg := fmt.Sprintf("commitments are not enabled for resource %s/%s", req.TargetService, req.TargetResource)
×
1393
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
1394
                return
×
1395
        }
×
1396
        rate, ok := sourceBehavior.GetConversionRateTo(targetBehavior).Unpack()
7✔
1397
        if !ok {
8✔
1398
                msg := fmt.Sprintf("commitment is not convertible into resource %s/%s", req.TargetService, req.TargetResource)
1✔
1399
                http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
1400
                return
1✔
1401
        }
1✔
1402

1403
        // section: conversion
1404
        if req.SourceAmount > dbCommitment.Amount {
6✔
1405
                msg := fmt.Sprintf("unprocessable source amount. provided: %v, commitment: %v", req.SourceAmount, dbCommitment.Amount)
×
1406
                http.Error(w, msg, http.StatusConflict)
×
1407
                return
×
1408
        }
×
1409
        conversionAmount := (req.SourceAmount / rate.FromAmount) * rate.ToAmount
6✔
1410
        remainderAmount := req.SourceAmount % rate.FromAmount
6✔
1411
        if remainderAmount > 0 {
8✔
1412
                msg := fmt.Sprintf("amount: %v does not fit into conversion rate of: %v", req.SourceAmount, rate.FromAmount)
2✔
1413
                http.Error(w, msg, http.StatusConflict)
2✔
1414
                return
2✔
1415
        }
2✔
1416
        if conversionAmount != req.TargetAmount {
5✔
1417
                msg := fmt.Sprintf("conversion mismatch. provided: %v, calculated: %v", req.TargetAmount, conversionAmount)
1✔
1418
                http.Error(w, msg, http.StatusConflict)
1✔
1419
                return
1✔
1420
        }
1✔
1421

1422
        tx, err := p.DB.Begin()
3✔
1423
        if respondwith.ErrorText(w, err) {
3✔
1424
                return
×
1425
        }
×
1426
        defer sqlext.RollbackUnlessCommitted(tx)
3✔
1427

3✔
1428
        var (
3✔
1429
                targetAZResourceID        db.ClusterAZResourceID
3✔
1430
                resourceAllowsCommitments bool
3✔
1431
        )
3✔
1432
        err = p.DB.QueryRow(findClusterAZResourceIDByLocationQuery, dbProject.ID, targetServiceType, targetResourceName, sourceLoc.AvailabilityZone).
3✔
1433
                Scan(&targetAZResourceID, &resourceAllowsCommitments)
3✔
1434
        if respondwith.ErrorText(w, err) {
3✔
1435
                return
×
1436
        }
×
1437
        // defense in depth. ServiceType and ResourceName of source and target are already checked. Here it's possible to explicitly check the ID's.
1438
        if dbCommitment.AZResourceID == targetAZResourceID {
3✔
1439
                http.Error(w, "conversion attempt to the same resource.", http.StatusConflict)
×
1440
                return
×
1441
        }
×
1442
        if !resourceAllowsCommitments {
3✔
NEW
1443
                msg := fmt.Sprintf("resource %s/%s is not enabled in this project", targetServiceType, targetResourceName)
×
NEW
1444
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
NEW
1445
                return
×
NEW
1446
        }
×
1447
        targetLoc := core.AZResourceLocation{
3✔
1448
                ServiceType:      targetServiceType,
3✔
1449
                ResourceName:     targetResourceName,
3✔
1450
                AvailabilityZone: sourceLoc.AvailabilityZone,
3✔
1451
        }
3✔
1452
        // The commitment at the source resource was already confirmed and checked.
3✔
1453
        // Therefore only the addition to the target resource has to be checked against.
3✔
1454
        if dbCommitment.ConfirmedAt.IsSome() {
5✔
1455
                ok, err := datamodel.CanConfirmNewCommitment(targetLoc, dbProject.ID, conversionAmount, p.Cluster, p.DB)
2✔
1456
                if respondwith.ErrorText(w, err) {
2✔
1457
                        return
×
1458
                }
×
1459
                if !ok {
3✔
1460
                        http.Error(w, "not enough capacity to confirm the commitment", http.StatusUnprocessableEntity)
1✔
1461
                        return
1✔
1462
                }
1✔
1463
        }
1464

1465
        auditEvent := commitmentEventTarget{
2✔
1466
                DomainID:    dbDomain.UUID,
2✔
1467
                DomainName:  dbDomain.Name,
2✔
1468
                ProjectID:   dbProject.UUID,
2✔
1469
                ProjectName: dbProject.Name,
2✔
1470
        }
2✔
1471

2✔
1472
        var (
2✔
1473
                relatedCommitmentIDs   []db.ProjectCommitmentID
2✔
1474
                relatedCommitmentUUIDs []db.ProjectCommitmentUUID
2✔
1475
        )
2✔
1476
        remainingAmount := dbCommitment.Amount - req.SourceAmount
2✔
1477
        serviceInfo := core.InfoForService(serviceInfos, sourceLoc.ServiceType)
2✔
1478
        resourceInfo := core.InfoForResource(serviceInfo, sourceLoc.ResourceName)
2✔
1479
        if remainingAmount > 0 {
3✔
1480
                remainingCommitment, err := p.buildSplitCommitment(dbCommitment, remainingAmount)
1✔
1481
                if respondwith.ErrorText(w, err) {
1✔
1482
                        return
×
1483
                }
×
1484
                relatedCommitmentIDs = append(relatedCommitmentIDs, remainingCommitment.ID)
1✔
1485
                relatedCommitmentUUIDs = append(relatedCommitmentUUIDs, remainingCommitment.UUID)
1✔
1486
                err = tx.Insert(&remainingCommitment)
1✔
1487
                if respondwith.ErrorText(w, err) {
1✔
1488
                        return
×
1489
                }
×
1490
                auditEvent.Commitments = append(auditEvent.Commitments,
1✔
1491
                        p.convertCommitmentToDisplayForm(remainingCommitment, sourceLoc, token, resourceInfo.Unit),
1✔
1492
                )
1✔
1493
        }
1494

1495
        convertedCommitment, err := p.buildConvertedCommitment(dbCommitment, targetAZResourceID, conversionAmount)
2✔
1496
        if respondwith.ErrorText(w, err) {
2✔
1497
                return
×
1498
        }
×
1499
        relatedCommitmentIDs = append(relatedCommitmentIDs, convertedCommitment.ID)
2✔
1500
        relatedCommitmentUUIDs = append(relatedCommitmentUUIDs, convertedCommitment.UUID)
2✔
1501
        err = tx.Insert(&convertedCommitment)
2✔
1502
        if respondwith.ErrorText(w, err) {
2✔
1503
                return
×
1504
        }
×
1505

1506
        // supersede the original commitment
1507
        now := p.timeNow()
2✔
1508
        supersedeContext := db.CommitmentWorkflowContext{
2✔
1509
                Reason:                 db.CommitmentReasonConvert,
2✔
1510
                RelatedCommitmentIDs:   relatedCommitmentIDs,
2✔
1511
                RelatedCommitmentUUIDs: relatedCommitmentUUIDs,
2✔
1512
        }
2✔
1513
        buf, err := json.Marshal(supersedeContext)
2✔
1514
        if respondwith.ErrorText(w, err) {
2✔
1515
                return
×
1516
        }
×
1517
        dbCommitment.State = db.CommitmentStateSuperseded
2✔
1518
        dbCommitment.SupersededAt = Some(now)
2✔
1519
        dbCommitment.SupersedeContextJSON = Some(json.RawMessage(buf))
2✔
1520
        _, err = tx.Update(&dbCommitment)
2✔
1521
        if respondwith.ErrorText(w, err) {
2✔
1522
                return
×
1523
        }
×
1524

1525
        err = tx.Commit()
2✔
1526
        if respondwith.ErrorText(w, err) {
2✔
1527
                return
×
1528
        }
×
1529

1530
        c := p.convertCommitmentToDisplayForm(convertedCommitment, targetLoc, token, resourceInfo.Unit)
2✔
1531
        auditEvent.Commitments = append([]limesresources.Commitment{c}, auditEvent.Commitments...)
2✔
1532
        auditEvent.WorkflowContext = Some(db.CommitmentWorkflowContext{
2✔
1533
                Reason:                 db.CommitmentReasonSplit,
2✔
1534
                RelatedCommitmentIDs:   []db.ProjectCommitmentID{dbCommitment.ID},
2✔
1535
                RelatedCommitmentUUIDs: []db.ProjectCommitmentUUID{dbCommitment.UUID},
2✔
1536
        })
2✔
1537
        p.auditor.Record(audittools.Event{
2✔
1538
                Time:       p.timeNow(),
2✔
1539
                Request:    r,
2✔
1540
                User:       token,
2✔
1541
                ReasonCode: http.StatusAccepted,
2✔
1542
                Action:     cadf.UpdateAction,
2✔
1543
                Target:     auditEvent,
2✔
1544
        })
2✔
1545

2✔
1546
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
2✔
1547
}
1548

1549
// ExtendCommitmentDuration handles POST /v1/domains/{domain_id}/projects/{project_id}/commitments/{commitment_id}/update-duration
1550
func (p *v1Provider) UpdateCommitmentDuration(w http.ResponseWriter, r *http.Request) {
6✔
1551
        httpapi.IdentifyEndpoint(r, "/v1/domains/:domain_id/projects/:project_id/commitments/:commitment_id/update-duration")
6✔
1552
        token := p.CheckToken(r)
6✔
1553
        if !token.Require(w, "project:edit") {
6✔
1554
                return
×
1555
        }
×
1556
        commitmentID := mux.Vars(r)["commitment_id"]
6✔
1557
        if commitmentID == "" {
6✔
1558
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
×
1559
                return
×
1560
        }
×
1561
        dbDomain := p.FindDomainFromRequest(w, r)
6✔
1562
        if dbDomain == nil {
6✔
1563
                return
×
1564
        }
×
1565
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
6✔
1566
        if dbProject == nil {
6✔
1567
                return
×
1568
        }
×
1569
        var Request struct {
6✔
1570
                Duration limesresources.CommitmentDuration `json:"duration"`
6✔
1571
        }
6✔
1572
        req := Request
6✔
1573
        if !RequireJSON(w, r, &req) {
6✔
1574
                return
×
1575
        }
×
1576

1577
        var dbCommitment db.ProjectCommitmentV2
6✔
1578
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, commitmentID, dbProject.ID)
6✔
1579
        if errors.Is(err, sql.ErrNoRows) {
6✔
1580
                http.Error(w, "no such commitment", http.StatusNotFound)
×
1581
                return
×
1582
        } else if respondwith.ErrorText(w, err) {
6✔
1583
                return
×
1584
        }
×
1585

1586
        now := p.timeNow()
6✔
1587
        if dbCommitment.ExpiresAt.Before(now) || dbCommitment.ExpiresAt.Equal(now) {
7✔
1588
                http.Error(w, "unable to process expired commitment", http.StatusForbidden)
1✔
1589
                return
1✔
1590
        }
1✔
1591

1592
        if dbCommitment.State == db.CommitmentStateSuperseded {
6✔
1593
                msg := fmt.Sprintf("unable to operate on commitment with a state of %s", dbCommitment.State)
1✔
1594
                http.Error(w, msg, http.StatusForbidden)
1✔
1595
                return
1✔
1596
        }
1✔
1597

1598
        var loc core.AZResourceLocation
4✔
1599
        err = p.DB.QueryRow(findClusterAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
4✔
1600
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
4✔
1601
        if errors.Is(err, sql.ErrNoRows) {
4✔
1602
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
1603
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
1604
                return
×
1605
        } else if respondwith.ErrorText(w, err) {
4✔
1606
                return
×
1607
        }
×
1608
        behavior := p.Cluster.CommitmentBehaviorForResource(loc.ServiceType, loc.ResourceName).ForDomain(dbDomain.Name)
4✔
1609
        if !slices.Contains(behavior.Durations, req.Duration) {
5✔
1610
                msg := fmt.Sprintf("provided duration: %s does not match the config %v", req.Duration, behavior.Durations)
1✔
1611
                http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
1612
                return
1✔
1613
        }
1✔
1614

1615
        newExpiresAt := req.Duration.AddTo(dbCommitment.ConfirmBy.UnwrapOr(dbCommitment.CreatedAt))
3✔
1616
        if newExpiresAt.Before(dbCommitment.ExpiresAt) {
4✔
1617
                msg := fmt.Sprintf("duration change from %s to %s forbidden", dbCommitment.Duration, req.Duration)
1✔
1618
                http.Error(w, msg, http.StatusForbidden)
1✔
1619
                return
1✔
1620
        }
1✔
1621

1622
        dbCommitment.Duration = req.Duration
2✔
1623
        dbCommitment.ExpiresAt = newExpiresAt
2✔
1624
        _, err = p.DB.Update(&dbCommitment)
2✔
1625
        if respondwith.ErrorText(w, err) {
2✔
1626
                return
×
1627
        }
×
1628

1629
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
2✔
1630
        if respondwith.ErrorText(w, err) {
2✔
1631
                return
×
1632
        }
×
1633
        serviceInfo, ok := maybeServiceInfo.Unpack()
2✔
1634
        if !ok {
2✔
1635
                http.Error(w, "service not found", http.StatusNotFound)
×
1636
                return
×
1637
        }
×
1638
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
2✔
1639
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token, resourceInfo.Unit)
2✔
1640
        p.auditor.Record(audittools.Event{
2✔
1641
                Time:       p.timeNow(),
2✔
1642
                Request:    r,
2✔
1643
                User:       token,
2✔
1644
                ReasonCode: http.StatusOK,
2✔
1645
                Action:     cadf.UpdateAction,
2✔
1646
                Target: commitmentEventTarget{
2✔
1647
                        DomainID:    dbDomain.UUID,
2✔
1648
                        DomainName:  dbDomain.Name,
2✔
1649
                        ProjectID:   dbProject.UUID,
2✔
1650
                        ProjectName: dbProject.Name,
2✔
1651
                        Commitments: []limesresources.Commitment{c},
2✔
1652
                },
2✔
1653
        })
2✔
1654

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