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

sapcc / limes / 16974348878

14 Aug 2025 07:08PM UTC coverage: 78.946% (+0.02%) from 78.924%
16974348878

push

github

sapcc-bot
Run go-makefile-maker and autoupdate dependencies

go: upgraded github.com/sapcc/go-bits v0.0.0-20250811141703-79d9564d7be1 => v0.0.0-20250814121725-04e492184b75

6843 of 8668 relevant lines covered (78.95%)

59.57 hits per line

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

77.53
/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/logg"
30
        "github.com/sapcc/go-bits/must"
31
        "github.com/sapcc/go-bits/respondwith"
32
        "github.com/sapcc/go-bits/sqlext"
33

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

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

51
        getAZResourceLocationsQuery = sqlext.SimplifyWhitespace(`
52
                SELECT cazr.id, cs.type, cr.name, cazr.az
53
                  FROM project_az_resources pazr
54
                  JOIN az_resources cazr on pazr.az_resource_id = cazr.id
55
                  JOIN resources cr ON cazr.resource_id = cr.id {{AND cr.name = $resource_name}}
56
                  JOIN services cs ON cr.service_id = cs.id {{AND cs.type = $service_type}}
57
                 WHERE %s
58
        `)
59

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

66
        findAZResourceIDByLocationQuery = sqlext.SimplifyWhitespace(`
67
                SELECT cazr.id, pr.forbidden IS NOT TRUE as resource_allows_commitments
68
                  FROM az_resources cazr
69
                  JOIN resources cr ON cazr.resource_id = cr.id
70
                  JOIN services cs ON cr.service_id = cs.id
71
                  JOIN project_resources pr ON pr.resource_id = cr.id
72
                 WHERE pr.project_id = $1 AND cs.type = $2 AND cr.name = $3 AND cazr.az = $4
73
        `)
74

75
        findAZResourceLocationByIDQuery = sqlext.SimplifyWhitespace(`
76
                SELECT cs.type, cr.name, cazr.az
77
                  FROM az_resources cazr
78
                  JOIN resources cr ON cazr.resource_id = cr.id
79
                  JOIN services cs ON cr.service_id = cs.id
80
                 WHERE cazr.id = $1
81
        `)
82
        getCommitmentWithMatchingTransferTokenQuery = sqlext.SimplifyWhitespace(`
83
                SELECT * FROM project_commitments WHERE id = $1 AND transfer_token = $2
84
        `)
85
        findCommitmentByTransferToken = sqlext.SimplifyWhitespace(`
86
                SELECT * FROM project_commitments WHERE transfer_token = $1
87
        `)
88
)
89

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

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

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

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

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

159
// The state in the db can be directly mapped to the liquid.CommitmentStatus.
160
// However, the state "active" is named "confirmed" in the API. If the persisted
161
// state cannot be mapped to liquid terms, an empty string is returned.
162
func (p *v1Provider) convertCommitmentStateToDisplayForm(c db.ProjectCommitment) liquid.CommitmentStatus {
65✔
163
        var status = liquid.CommitmentStatus(c.State)
65✔
164
        if c.State == "active" {
103✔
165
                status = liquid.CommitmentStatusConfirmed
38✔
166
        }
38✔
167
        if status.IsValid() {
129✔
168
                return status
64✔
169
        }
64✔
170
        return "" // An empty state will be omitted when json serialized.
1✔
171
}
172

173
func (p *v1Provider) convertCommitmentToDisplayForm(c db.ProjectCommitment, loc core.AZResourceLocation, token *gopherpolicy.Token, unit limes.Unit) limesresources.Commitment {
59✔
174
        apiIdentity := p.Cluster.BehaviorForResource(loc.ServiceType, loc.ResourceName).IdentityInV1API
59✔
175
        return limesresources.Commitment{
59✔
176
                ID:               int64(c.ID),
59✔
177
                UUID:             string(c.UUID),
59✔
178
                ServiceType:      apiIdentity.ServiceType,
59✔
179
                ResourceName:     apiIdentity.Name,
59✔
180
                AvailabilityZone: loc.AvailabilityZone,
59✔
181
                Amount:           c.Amount,
59✔
182
                Unit:             unit,
59✔
183
                Duration:         c.Duration,
59✔
184
                CreatedAt:        limes.UnixEncodedTime{Time: c.CreatedAt},
59✔
185
                CreatorUUID:      c.CreatorUUID,
59✔
186
                CreatorName:      c.CreatorName,
59✔
187
                CanBeDeleted:     p.canDeleteCommitment(token, c),
59✔
188
                ConfirmBy:        options.Map(c.ConfirmBy, intoUnixEncodedTime).AsPointer(),
59✔
189
                ConfirmedAt:      options.Map(c.ConfirmedAt, intoUnixEncodedTime).AsPointer(),
59✔
190
                ExpiresAt:        limes.UnixEncodedTime{Time: c.ExpiresAt},
59✔
191
                TransferStatus:   c.TransferStatus,
59✔
192
                TransferToken:    c.TransferToken.AsPointer(),
59✔
193
                Status:           p.convertCommitmentStateToDisplayForm(c),
59✔
194
                NotifyOnConfirm:  c.NotifyOnConfirm,
59✔
195
                WasRenewed:       c.RenewContextJSON.IsSome(),
59✔
196
        }
59✔
197
}
59✔
198

199
// parseAndValidateCommitmentRequest parses and validates the request body for a commitment creation or confirmation.
200
// This function in its current form should only be used if the serviceInfo is not necessary to be used outside
201
// of this validation to avoid unnecessary database queries.
202
func (p *v1Provider) parseAndValidateCommitmentRequest(w http.ResponseWriter, r *http.Request, dbDomain db.Domain) (*limesresources.CommitmentRequest, *core.AZResourceLocation, *core.ScopedCommitmentBehavior) {
46✔
203
        // parse request
46✔
204
        var parseTarget struct {
46✔
205
                Request limesresources.CommitmentRequest `json:"commitment"`
46✔
206
        }
46✔
207
        if !RequireJSON(w, r, &parseTarget) {
47✔
208
                return nil, nil, nil
1✔
209
        }
1✔
210
        req := parseTarget.Request
45✔
211

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

253
        loc := core.AZResourceLocation{
35✔
254
                ServiceType:      dbServiceType,
35✔
255
                ResourceName:     dbResourceName,
35✔
256
                AvailabilityZone: req.AvailabilityZone,
35✔
257
        }
35✔
258
        return &req, &loc, &behavior
35✔
259
}
260

261
// CanConfirmNewProjectCommitment handles POST /v1/domains/:domain_id/projects/:project_id/commitments/can-confirm.
262
func (p *v1Provider) CanConfirmNewProjectCommitment(w http.ResponseWriter, r *http.Request) {
7✔
263
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/can-confirm")
7✔
264
        token := p.CheckToken(r)
7✔
265
        if !token.Require(w, "project:edit") {
7✔
266
                return
×
267
        }
×
268
        dbDomain := p.FindDomainFromRequest(w, r)
7✔
269
        if dbDomain == nil {
7✔
270
                return
×
271
        }
×
272
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
7✔
273
        if dbProject == nil {
7✔
274
                return
×
275
        }
×
276
        req, loc, behavior := p.parseAndValidateCommitmentRequest(w, r, *dbDomain)
7✔
277
        if req == nil {
7✔
278
                return
×
279
        }
×
280

281
        var (
7✔
282
                azResourceID              db.AZResourceID
7✔
283
                resourceAllowsCommitments bool
7✔
284
        )
7✔
285
        err := p.DB.QueryRow(findAZResourceIDByLocationQuery, dbProject.ID, loc.ServiceType, loc.ResourceName, loc.AvailabilityZone).
7✔
286
                Scan(&azResourceID, &resourceAllowsCommitments)
7✔
287
        if respondwith.ObfuscatedErrorText(w, err) {
7✔
288
                return
×
289
        }
×
290
        if !resourceAllowsCommitments {
7✔
291
                msg := fmt.Sprintf("resource %s/%s is not enabled in this project", req.ServiceType, req.ResourceName)
×
292
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
293
                return
×
294
        }
×
295
        _ = azResourceID // returned by the above query, but not used in this function
7✔
296

7✔
297
        // commitments can never be confirmed immediately if we are before the min_confirm_date
7✔
298
        now := p.timeNow()
7✔
299
        if !behavior.CanConfirmCommitmentsAt(now) {
8✔
300
                respondwith.JSON(w, http.StatusOK, map[string]bool{"result": false})
1✔
301
                return
1✔
302
        }
1✔
303

304
        // check for committable capacity
305
        result, err := datamodel.CanConfirmNewCommitment(*loc, dbProject.ID, req.Amount, p.Cluster, p.DB)
6✔
306
        if respondwith.ObfuscatedErrorText(w, err) {
6✔
307
                return
×
308
        }
×
309
        respondwith.JSON(w, http.StatusOK, map[string]bool{"result": result})
6✔
310
}
311

312
// CreateProjectCommitment handles POST /v1/domains/:domain_id/projects/:project_id/commitments/new.
313
func (p *v1Provider) CreateProjectCommitment(w http.ResponseWriter, r *http.Request) {
42✔
314
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/new")
42✔
315
        token := p.CheckToken(r)
42✔
316
        if !token.Require(w, "project:edit") {
43✔
317
                return
1✔
318
        }
1✔
319
        dbDomain := p.FindDomainFromRequest(w, r)
41✔
320
        if dbDomain == nil {
42✔
321
                return
1✔
322
        }
1✔
323
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
40✔
324
        if dbProject == nil {
41✔
325
                return
1✔
326
        }
1✔
327
        req, loc, behavior := p.parseAndValidateCommitmentRequest(w, r, *dbDomain)
39✔
328
        if req == nil {
50✔
329
                return
11✔
330
        }
11✔
331

332
        var (
28✔
333
                azResourceID              db.AZResourceID
28✔
334
                resourceAllowsCommitments bool
28✔
335
        )
28✔
336
        err := p.DB.QueryRow(findAZResourceIDByLocationQuery, dbProject.ID, loc.ServiceType, loc.ResourceName, loc.AvailabilityZone).
28✔
337
                Scan(&azResourceID, &resourceAllowsCommitments)
28✔
338
        if respondwith.ObfuscatedErrorText(w, err) {
28✔
339
                return
×
340
        }
×
341
        if !resourceAllowsCommitments {
29✔
342
                msg := fmt.Sprintf("resource %s/%s is not enabled in this project", req.ServiceType, req.ResourceName)
1✔
343
                http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
344
                return
1✔
345
        }
1✔
346

347
        // if given, confirm_by must definitely after time.Now(), and also after the MinConfirmDate if configured
348
        now := p.timeNow()
27✔
349
        if req.ConfirmBy != nil && req.ConfirmBy.Before(now) {
28✔
350
                http.Error(w, "confirm_by must not be set in the past", http.StatusUnprocessableEntity)
1✔
351
                return
1✔
352
        }
1✔
353
        if minConfirmBy, ok := behavior.MinConfirmDate.Unpack(); ok && minConfirmBy.After(now) {
31✔
354
                if req.ConfirmBy == nil || req.ConfirmBy.Before(minConfirmBy) {
6✔
355
                        msg := "this commitment needs a `confirm_by` timestamp at or after " + minConfirmBy.Format(time.RFC3339)
1✔
356
                        http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
357
                        return
1✔
358
                }
1✔
359
        }
360

361
        // we want to validate committable capacity in the same transaction that creates the commitment
362
        tx, err := p.DB.Begin()
25✔
363
        if respondwith.ObfuscatedErrorText(w, err) {
25✔
364
                return
×
365
        }
×
366
        defer sqlext.RollbackUnlessCommitted(tx)
25✔
367

25✔
368
        // prepare commitment
25✔
369
        confirmBy := options.Map(options.FromPointer(req.ConfirmBy), fromUnixEncodedTime)
25✔
370
        creationContext := db.CommitmentWorkflowContext{Reason: db.CommitmentReasonCreate}
25✔
371
        buf, err := json.Marshal(creationContext)
25✔
372
        if respondwith.ObfuscatedErrorText(w, err) {
25✔
373
                return
×
374
        }
×
375
        dbCommitment := db.ProjectCommitment{
25✔
376
                UUID:                p.generateProjectCommitmentUUID(),
25✔
377
                AZResourceID:        azResourceID,
25✔
378
                ProjectID:           dbProject.ID,
25✔
379
                Amount:              req.Amount,
25✔
380
                Duration:            req.Duration,
25✔
381
                CreatedAt:           now,
25✔
382
                CreatorUUID:         token.UserUUID(),
25✔
383
                CreatorName:         fmt.Sprintf("%s@%s", token.UserName(), token.UserDomainName()),
25✔
384
                ConfirmBy:           confirmBy,
25✔
385
                ConfirmedAt:         None[time.Time](), // may be set below
25✔
386
                ExpiresAt:           req.Duration.AddTo(confirmBy.UnwrapOr(now)),
25✔
387
                CreationContextJSON: json.RawMessage(buf),
25✔
388
        }
25✔
389
        if req.NotifyOnConfirm && req.ConfirmBy == nil {
26✔
390
                http.Error(w, "notification on confirm cannot be set for commitments with immediate confirmation", http.StatusConflict)
1✔
391
                return
1✔
392
        }
1✔
393
        dbCommitment.NotifyOnConfirm = req.NotifyOnConfirm
24✔
394

24✔
395
        if req.ConfirmBy == nil {
42✔
396
                // if not planned for confirmation in the future, confirm immediately (or fail)
18✔
397
                ok, err := datamodel.CanConfirmNewCommitment(*loc, dbProject.ID, req.Amount, p.Cluster, tx)
18✔
398
                if respondwith.ObfuscatedErrorText(w, err) {
18✔
399
                        return
×
400
                }
×
401
                if !ok {
18✔
402
                        http.Error(w, "not enough capacity available for immediate confirmation", http.StatusConflict)
×
403
                        return
×
404
                }
×
405
                dbCommitment.ConfirmedAt = Some(now)
18✔
406
                dbCommitment.State = db.CommitmentStateActive
18✔
407
        } else {
6✔
408
                dbCommitment.State = db.CommitmentStatePlanned
6✔
409
        }
6✔
410

411
        // create commitment
412
        err = tx.Insert(&dbCommitment)
24✔
413
        if respondwith.ObfuscatedErrorText(w, err) {
24✔
414
                return
×
415
        }
×
416

417
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
24✔
418
        if respondwith.ObfuscatedErrorText(w, err) {
24✔
419
                return
×
420
        }
×
421
        serviceInfo, ok := maybeServiceInfo.Unpack()
24✔
422
        if !ok {
24✔
423
                http.Error(w, "service not found", http.StatusNotFound)
×
424
                return
×
425
        }
×
426

427
        err = tx.Commit()
24✔
428
        if respondwith.ObfuscatedErrorText(w, err) {
24✔
429
                return
×
430
        }
×
431

432
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
24✔
433
        commitment := p.convertCommitmentToDisplayForm(dbCommitment, *loc, token, resourceInfo.Unit)
24✔
434
        p.auditor.Record(audittools.Event{
24✔
435
                Time:       now,
24✔
436
                Request:    r,
24✔
437
                User:       token,
24✔
438
                ReasonCode: http.StatusCreated,
24✔
439
                Action:     cadf.CreateAction,
24✔
440
                Target: commitmentEventTarget{
24✔
441
                        DomainID:        dbDomain.UUID,
24✔
442
                        DomainName:      dbDomain.Name,
24✔
443
                        ProjectID:       dbProject.UUID,
24✔
444
                        ProjectName:     dbProject.Name,
24✔
445
                        Commitments:     []limesresources.Commitment{commitment},
24✔
446
                        WorkflowContext: Some(creationContext),
24✔
447
                },
24✔
448
        })
24✔
449

24✔
450
        // if the commitment is immediately confirmed, trigger a capacity scrape in
24✔
451
        // order to ApplyComputedProjectQuotas based on the new commitment
24✔
452
        if dbCommitment.ConfirmedAt.IsSome() {
42✔
453
                _, err := p.DB.Exec(`UPDATE services SET next_scrape_at = $1 WHERE type = $2`, now, loc.ServiceType)
18✔
454
                if err != nil {
18✔
455
                        logg.Error("could not trigger a new capacity scrape after creating commitment %s: %s", dbCommitment.UUID, err.Error())
×
456
                }
×
457
        }
458

459
        respondwith.JSON(w, http.StatusCreated, map[string]any{"commitment": commitment})
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✔
481
                return
×
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
        commitmentUUIDs := make([]db.ProjectCommitmentUUID, len(commitmentIDs))
8✔
492
        for i, commitmentID := range commitmentIDs {
24✔
493
                err := p.DB.SelectOne(&dbCommitments[i], findProjectCommitmentByIDQuery, commitmentID, dbProject.ID)
16✔
494
                if errors.Is(err, sql.ErrNoRows) {
17✔
495
                        http.Error(w, "no such commitment", http.StatusNotFound)
1✔
496
                        return
1✔
497
                } else if respondwith.ObfuscatedErrorText(w, err) {
16✔
498
                        return
×
499
                }
×
500
                commitmentUUIDs[i] = dbCommitments[i].UUID
15✔
501
        }
502

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

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

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

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

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

558
        // Fill workflow context
559
        creationContext := db.CommitmentWorkflowContext{
1✔
560
                Reason:                 db.CommitmentReasonMerge,
1✔
561
                RelatedCommitmentIDs:   commitmentIDs,
1✔
562
                RelatedCommitmentUUIDs: commitmentUUIDs,
1✔
563
        }
1✔
564
        buf, err := json.Marshal(creationContext)
1✔
565
        if respondwith.ObfuscatedErrorText(w, err) {
1✔
566
                return
×
567
        }
×
568
        dbMergedCommitment.CreationContextJSON = json.RawMessage(buf)
1✔
569

1✔
570
        // Insert into database
1✔
571
        err = tx.Insert(&dbMergedCommitment)
1✔
572
        if respondwith.ObfuscatedErrorText(w, err) {
1✔
573
                return
×
574
        }
×
575

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

596
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
1✔
597
        if respondwith.ObfuscatedErrorText(w, err) {
1✔
598
                return
×
599
        }
×
600
        serviceInfo, ok := maybeServiceInfo.Unpack()
1✔
601
        if !ok {
1✔
602
                http.Error(w, "service not found", http.StatusNotFound)
×
603
                return
×
604
        }
×
605

606
        err = tx.Commit()
1✔
607
        if respondwith.ObfuscatedErrorText(w, err) {
1✔
608
                return
×
609
        }
×
610

611
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
1✔
612
        c := p.convertCommitmentToDisplayForm(dbMergedCommitment, loc, token, resourceInfo.Unit)
1✔
613
        auditEvent := commitmentEventTarget{
1✔
614
                DomainID:        dbDomain.UUID,
1✔
615
                DomainName:      dbDomain.Name,
1✔
616
                ProjectID:       dbProject.UUID,
1✔
617
                ProjectName:     dbProject.Name,
1✔
618
                Commitments:     []limesresources.Commitment{c},
1✔
619
                WorkflowContext: Some(creationContext),
1✔
620
        }
1✔
621
        p.auditor.Record(audittools.Event{
1✔
622
                Time:       p.timeNow(),
1✔
623
                Request:    r,
1✔
624
                User:       token,
1✔
625
                ReasonCode: http.StatusAccepted,
1✔
626
                Action:     cadf.UpdateAction,
1✔
627
                Target:     auditEvent,
1✔
628
        })
1✔
629

1✔
630
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
1✔
631
}
632

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

636
// RenewProjectCommitments handles POST /v1/domains/:domain_id/projects/:project_id/commitments/:id/renew.
637
func (p *v1Provider) RenewProjectCommitments(w http.ResponseWriter, r *http.Request) {
6✔
638
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/:id/renew")
6✔
639
        token := p.CheckToken(r)
6✔
640
        if !token.Require(w, "project:edit") {
6✔
641
                return
×
642
        }
×
643
        dbDomain := p.FindDomainFromRequest(w, r)
6✔
644
        if dbDomain == nil {
6✔
645
                return
×
646
        }
×
647
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
6✔
648
        if dbProject == nil {
6✔
649
                return
×
650
        }
×
651

652
        // Load commitment
653
        var dbCommitment db.ProjectCommitment
6✔
654
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, mux.Vars(r)["id"], dbProject.ID)
6✔
655
        if errors.Is(err, sql.ErrNoRows) {
6✔
656
                http.Error(w, "no such commitment", http.StatusNotFound)
×
657
                return
×
658
        } else if respondwith.ObfuscatedErrorText(w, err) {
6✔
659
                return
×
660
        }
×
661
        now := p.timeNow()
6✔
662

6✔
663
        // Check if commitment can be renewed
6✔
664
        var errs errext.ErrorSet
6✔
665
        if dbCommitment.State != db.CommitmentStateActive {
7✔
666
                errs.Addf("invalid state %q", dbCommitment.State)
1✔
667
        } else if now.After(dbCommitment.ExpiresAt) {
7✔
668
                errs.Addf("invalid state %q", db.CommitmentStateExpired)
1✔
669
        }
1✔
670
        if now.Before(dbCommitment.ExpiresAt.Add(-commitmentRenewalPeriod)) {
7✔
671
                errs.Addf("renewal attempt too early")
1✔
672
        }
1✔
673
        if dbCommitment.RenewContextJSON.IsSome() {
7✔
674
                errs.Addf("already renewed")
1✔
675
        }
1✔
676

677
        if !errs.IsEmpty() {
10✔
678
                msg := "cannot renew this commitment: " + errs.Join(", ")
4✔
679
                http.Error(w, msg, http.StatusConflict)
4✔
680
                return
4✔
681
        }
4✔
682

683
        // Create renewed commitment
684
        tx, err := p.DB.Begin()
2✔
685
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
686
                return
×
687
        }
×
688
        defer sqlext.RollbackUnlessCommitted(tx)
2✔
689

2✔
690
        var loc core.AZResourceLocation
2✔
691
        err = tx.QueryRow(findAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
2✔
692
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
2✔
693
        if errors.Is(err, sql.ErrNoRows) {
2✔
694
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
695
                return
×
696
        } else if respondwith.ObfuscatedErrorText(w, err) {
2✔
697
                return
×
698
        }
×
699

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

2✔
724
        err = tx.Insert(&dbRenewedCommitment)
2✔
725
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
726
                return
×
727
        }
×
728

729
        renewContext := db.CommitmentWorkflowContext{
2✔
730
                Reason:                 db.CommitmentReasonRenew,
2✔
731
                RelatedCommitmentIDs:   []db.ProjectCommitmentID{dbRenewedCommitment.ID},
2✔
732
                RelatedCommitmentUUIDs: []db.ProjectCommitmentUUID{dbRenewedCommitment.UUID},
2✔
733
        }
2✔
734
        buf, err = json.Marshal(renewContext)
2✔
735
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
736
                return
×
737
        }
×
738
        dbCommitment.RenewContextJSON = Some(json.RawMessage(buf))
2✔
739
        _, err = tx.Update(&dbCommitment)
2✔
740
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
741
                return
×
742
        }
×
743

744
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
2✔
745
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
746
                return
×
747
        }
×
748
        serviceInfo, ok := maybeServiceInfo.Unpack()
2✔
749
        if !ok {
2✔
750
                http.Error(w, "service not found", http.StatusNotFound)
×
751
                return
×
752
        }
×
753

754
        err = tx.Commit()
2✔
755
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
756
                return
×
757
        }
×
758

759
        // Create resultset and auditlogs
760
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
2✔
761
        c := p.convertCommitmentToDisplayForm(dbRenewedCommitment, loc, token, resourceInfo.Unit)
2✔
762
        auditEvent := commitmentEventTarget{
2✔
763
                DomainID:        dbDomain.UUID,
2✔
764
                DomainName:      dbDomain.Name,
2✔
765
                ProjectID:       dbProject.UUID,
2✔
766
                ProjectName:     dbProject.Name,
2✔
767
                Commitments:     []limesresources.Commitment{c},
2✔
768
                WorkflowContext: Some(creationContext),
2✔
769
        }
2✔
770

2✔
771
        p.auditor.Record(audittools.Event{
2✔
772
                Time:       p.timeNow(),
2✔
773
                Request:    r,
2✔
774
                User:       token,
2✔
775
                ReasonCode: http.StatusAccepted,
2✔
776
                Action:     cadf.UpdateAction,
2✔
777
                Target:     auditEvent,
2✔
778
        })
2✔
779

2✔
780
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
2✔
781
}
782

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

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

819
        // check authorization for this specific commitment
820
        if !p.canDeleteCommitment(token, dbCommitment) {
6✔
821
                http.Error(w, "Forbidden", http.StatusForbidden)
1✔
822
                return
1✔
823
        }
1✔
824

825
        // perform deletion
826
        _, err = p.DB.Delete(&dbCommitment)
4✔
827
        if respondwith.ObfuscatedErrorText(w, err) {
4✔
828
                return
×
829
        }
×
830
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
4✔
831
        if respondwith.ObfuscatedErrorText(w, err) {
4✔
832
                return
×
833
        }
×
834
        serviceInfo, ok := maybeServiceInfo.Unpack()
4✔
835
        if !ok {
4✔
836
                http.Error(w, "service not found", http.StatusNotFound)
×
837
                return
×
838
        }
×
839
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
4✔
840
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token, resourceInfo.Unit)
4✔
841
        p.auditor.Record(audittools.Event{
4✔
842
                Time:       p.timeNow(),
4✔
843
                Request:    r,
4✔
844
                User:       token,
4✔
845
                ReasonCode: http.StatusNoContent,
4✔
846
                Action:     cadf.DeleteAction,
4✔
847
                Target: commitmentEventTarget{
4✔
848
                        DomainID:    dbDomain.UUID,
4✔
849
                        DomainName:  dbDomain.Name,
4✔
850
                        ProjectID:   dbProject.UUID,
4✔
851
                        ProjectName: dbProject.Name,
4✔
852
                        Commitments: []limesresources.Commitment{c},
4✔
853
                },
4✔
854
        })
4✔
855

4✔
856
        w.WriteHeader(http.StatusNoContent)
4✔
857
}
858

859
func (p *v1Provider) canDeleteCommitment(token *gopherpolicy.Token, commitment db.ProjectCommitment) bool {
64✔
860
        // up to 24 hours after creation of fresh commitments, future commitments can still be deleted by their creators
64✔
861
        if commitment.State == db.CommitmentStatePlanned || commitment.State == db.CommitmentStatePending || commitment.State == db.CommitmentStateActive {
128✔
862
                var creationContext db.CommitmentWorkflowContext
64✔
863
                err := json.Unmarshal(commitment.CreationContextJSON, &creationContext)
64✔
864
                if err == nil && creationContext.Reason == db.CommitmentReasonCreate && p.timeNow().Before(commitment.CreatedAt.Add(24*time.Hour)) {
104✔
865
                        if token.Check("project:edit") {
80✔
866
                                return true
40✔
867
                        }
40✔
868
                }
869
        }
870

871
        // afterwards, a more specific permission is required to delete it
872
        //
873
        // This protects cloud admins making capacity planning decisions based on future commitments
874
        // from having their forecasts ruined by project admins suffering from buyer's remorse.
875
        return token.Check("project:uncommit")
24✔
876
}
877

878
// StartCommitmentTransfer handles POST /v1/domains/:id/projects/:id/commitments/:id/start-transfer
879
func (p *v1Provider) StartCommitmentTransfer(w http.ResponseWriter, r *http.Request) {
8✔
880
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/:id/start-transfer")
8✔
881
        token := p.CheckToken(r)
8✔
882
        if !token.Require(w, "project:edit") {
8✔
883
                return
×
884
        }
×
885
        dbDomain := p.FindDomainFromRequest(w, r)
8✔
886
        if dbDomain == nil {
8✔
887
                return
×
888
        }
×
889
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
8✔
890
        if dbProject == nil {
8✔
891
                return
×
892
        }
×
893
        // TODO: eventually migrate this struct into go-api-declarations
894
        var parseTarget struct {
8✔
895
                Request struct {
8✔
896
                        Amount         uint64                                  `json:"amount"`
8✔
897
                        TransferStatus limesresources.CommitmentTransferStatus `json:"transfer_status,omitempty"`
8✔
898
                } `json:"commitment"`
8✔
899
        }
8✔
900
        if !RequireJSON(w, r, &parseTarget) {
8✔
901
                return
×
902
        }
×
903
        req := parseTarget.Request
8✔
904

8✔
905
        if req.TransferStatus != limesresources.CommitmentTransferStatusUnlisted && req.TransferStatus != limesresources.CommitmentTransferStatusPublic {
8✔
906
                http.Error(w, fmt.Sprintf("Invalid transfer_status code. Must be %s or %s.", limesresources.CommitmentTransferStatusUnlisted, limesresources.CommitmentTransferStatusPublic), http.StatusBadRequest)
×
907
                return
×
908
        }
×
909

910
        if req.Amount <= 0 {
9✔
911
                http.Error(w, "delivered amount needs to be a positive value.", http.StatusBadRequest)
1✔
912
                return
1✔
913
        }
1✔
914

915
        // load commitment
916
        var dbCommitment db.ProjectCommitment
7✔
917
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, mux.Vars(r)["id"], dbProject.ID)
7✔
918
        if errors.Is(err, sql.ErrNoRows) {
7✔
919
                http.Error(w, "no such commitment", http.StatusNotFound)
×
920
                return
×
921
        } else if respondwith.ObfuscatedErrorText(w, err) {
7✔
922
                return
×
923
        }
×
924

925
        // Mark whole commitment or a newly created, splitted one as transferrable.
926
        tx, err := p.DB.Begin()
7✔
927
        if respondwith.ObfuscatedErrorText(w, err) {
7✔
928
                return
×
929
        }
×
930
        defer sqlext.RollbackUnlessCommitted(tx)
7✔
931
        transferToken := p.generateTransferToken()
7✔
932

7✔
933
        // Deny requests with a greater amount than the commitment.
7✔
934
        if req.Amount > dbCommitment.Amount {
8✔
935
                http.Error(w, "delivered amount exceeds the commitment amount.", http.StatusBadRequest)
1✔
936
                return
1✔
937
        }
1✔
938

939
        if req.Amount == dbCommitment.Amount {
10✔
940
                dbCommitment.TransferStatus = req.TransferStatus
4✔
941
                dbCommitment.TransferToken = Some(transferToken)
4✔
942
                _, err = tx.Update(&dbCommitment)
4✔
943
                if respondwith.ObfuscatedErrorText(w, err) {
4✔
944
                        return
×
945
                }
×
946
        } else {
2✔
947
                now := p.timeNow()
2✔
948
                transferAmount := req.Amount
2✔
949
                remainingAmount := dbCommitment.Amount - req.Amount
2✔
950
                transferCommitment, err := p.buildSplitCommitment(dbCommitment, transferAmount)
2✔
951
                if respondwith.ObfuscatedErrorText(w, err) {
2✔
952
                        return
×
953
                }
×
954
                transferCommitment.TransferStatus = req.TransferStatus
2✔
955
                transferCommitment.TransferToken = Some(transferToken)
2✔
956
                remainingCommitment, err := p.buildSplitCommitment(dbCommitment, remainingAmount)
2✔
957
                if respondwith.ObfuscatedErrorText(w, err) {
2✔
958
                        return
×
959
                }
×
960
                err = tx.Insert(&transferCommitment)
2✔
961
                if respondwith.ObfuscatedErrorText(w, err) {
2✔
962
                        return
×
963
                }
×
964
                err = tx.Insert(&remainingCommitment)
2✔
965
                if respondwith.ObfuscatedErrorText(w, err) {
2✔
966
                        return
×
967
                }
×
968
                supersedeContext := db.CommitmentWorkflowContext{
2✔
969
                        Reason:                 db.CommitmentReasonSplit,
2✔
970
                        RelatedCommitmentIDs:   []db.ProjectCommitmentID{transferCommitment.ID, remainingCommitment.ID},
2✔
971
                        RelatedCommitmentUUIDs: []db.ProjectCommitmentUUID{transferCommitment.UUID, remainingCommitment.UUID},
2✔
972
                }
2✔
973
                buf, err := json.Marshal(supersedeContext)
2✔
974
                if respondwith.ObfuscatedErrorText(w, err) {
2✔
975
                        return
×
976
                }
×
977
                dbCommitment.State = db.CommitmentStateSuperseded
2✔
978
                dbCommitment.SupersededAt = Some(now)
2✔
979
                dbCommitment.SupersedeContextJSON = Some(json.RawMessage(buf))
2✔
980
                _, err = tx.Update(&dbCommitment)
2✔
981
                if respondwith.ObfuscatedErrorText(w, err) {
2✔
982
                        return
×
983
                }
×
984
                dbCommitment = transferCommitment
2✔
985
        }
986

987
        var loc core.AZResourceLocation
6✔
988
        err = tx.QueryRow(findAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
6✔
989
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
6✔
990
        if errors.Is(err, sql.ErrNoRows) {
6✔
991
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
992
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
993
                return
×
994
        } else if respondwith.ObfuscatedErrorText(w, err) {
6✔
995
                return
×
996
        }
×
997

998
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
6✔
999
        if respondwith.ObfuscatedErrorText(w, err) {
6✔
1000
                return
×
1001
        }
×
1002
        serviceInfo, ok := maybeServiceInfo.Unpack()
6✔
1003
        if !ok {
6✔
1004
                http.Error(w, "service not found", http.StatusNotFound)
×
1005
                return
×
1006
        }
×
1007

1008
        err = tx.Commit()
6✔
1009
        if respondwith.ObfuscatedErrorText(w, err) {
6✔
1010
                return
×
1011
        }
×
1012

1013
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
6✔
1014
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token, resourceInfo.Unit)
6✔
1015
        p.auditor.Record(audittools.Event{
6✔
1016
                Time:       p.timeNow(),
6✔
1017
                Request:    r,
6✔
1018
                User:       token,
6✔
1019
                ReasonCode: http.StatusAccepted,
6✔
1020
                Action:     cadf.UpdateAction,
6✔
1021
                Target: commitmentEventTarget{
6✔
1022
                        DomainID:    dbDomain.UUID,
6✔
1023
                        DomainName:  dbDomain.Name,
6✔
1024
                        ProjectID:   dbProject.UUID,
6✔
1025
                        ProjectName: dbProject.Name,
6✔
1026
                        Commitments: []limesresources.Commitment{c},
6✔
1027
                },
6✔
1028
        })
6✔
1029
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
6✔
1030
}
1031

1032
func (p *v1Provider) buildSplitCommitment(dbCommitment db.ProjectCommitment, amount uint64) (db.ProjectCommitment, error) {
5✔
1033
        now := p.timeNow()
5✔
1034
        creationContext := db.CommitmentWorkflowContext{
5✔
1035
                Reason:                 db.CommitmentReasonSplit,
5✔
1036
                RelatedCommitmentIDs:   []db.ProjectCommitmentID{dbCommitment.ID},
5✔
1037
                RelatedCommitmentUUIDs: []db.ProjectCommitmentUUID{dbCommitment.UUID},
5✔
1038
        }
5✔
1039
        buf, err := json.Marshal(creationContext)
5✔
1040
        if err != nil {
5✔
1041
                return db.ProjectCommitment{}, err
×
1042
        }
×
1043
        return db.ProjectCommitment{
5✔
1044
                UUID:                p.generateProjectCommitmentUUID(),
5✔
1045
                ProjectID:           dbCommitment.ProjectID,
5✔
1046
                AZResourceID:        dbCommitment.AZResourceID,
5✔
1047
                Amount:              amount,
5✔
1048
                Duration:            dbCommitment.Duration,
5✔
1049
                CreatedAt:           now,
5✔
1050
                CreatorUUID:         dbCommitment.CreatorUUID,
5✔
1051
                CreatorName:         dbCommitment.CreatorName,
5✔
1052
                ConfirmBy:           dbCommitment.ConfirmBy,
5✔
1053
                ConfirmedAt:         dbCommitment.ConfirmedAt,
5✔
1054
                ExpiresAt:           dbCommitment.ExpiresAt,
5✔
1055
                CreationContextJSON: json.RawMessage(buf),
5✔
1056
                State:               dbCommitment.State,
5✔
1057
        }, nil
5✔
1058
}
1059

1060
func (p *v1Provider) buildConvertedCommitment(dbCommitment db.ProjectCommitment, azResourceID db.AZResourceID, amount uint64) (db.ProjectCommitment, error) {
2✔
1061
        now := p.timeNow()
2✔
1062
        creationContext := db.CommitmentWorkflowContext{
2✔
1063
                Reason:                 db.CommitmentReasonConvert,
2✔
1064
                RelatedCommitmentIDs:   []db.ProjectCommitmentID{dbCommitment.ID},
2✔
1065
                RelatedCommitmentUUIDs: []db.ProjectCommitmentUUID{dbCommitment.UUID},
2✔
1066
        }
2✔
1067
        buf, err := json.Marshal(creationContext)
2✔
1068
        if err != nil {
2✔
1069
                return db.ProjectCommitment{}, err
×
1070
        }
×
1071
        return db.ProjectCommitment{
2✔
1072
                UUID:                p.generateProjectCommitmentUUID(),
2✔
1073
                ProjectID:           dbCommitment.ProjectID,
2✔
1074
                AZResourceID:        azResourceID,
2✔
1075
                Amount:              amount,
2✔
1076
                Duration:            dbCommitment.Duration,
2✔
1077
                CreatedAt:           now,
2✔
1078
                CreatorUUID:         dbCommitment.CreatorUUID,
2✔
1079
                CreatorName:         dbCommitment.CreatorName,
2✔
1080
                ConfirmBy:           dbCommitment.ConfirmBy,
2✔
1081
                ConfirmedAt:         dbCommitment.ConfirmedAt,
2✔
1082
                ExpiresAt:           dbCommitment.ExpiresAt,
2✔
1083
                CreationContextJSON: json.RawMessage(buf),
2✔
1084
                State:               dbCommitment.State,
2✔
1085
        }, nil
2✔
1086
}
1087

1088
// GetCommitmentByTransferToken handles GET /v1/commitments/{token}
1089
func (p *v1Provider) GetCommitmentByTransferToken(w http.ResponseWriter, r *http.Request) {
2✔
1090
        httpapi.IdentifyEndpoint(r, "/v1/commitments/:token")
2✔
1091
        token := p.CheckToken(r)
2✔
1092
        if !token.Require(w, "cluster:show_basic") {
2✔
1093
                return
×
1094
        }
×
1095
        transferToken := mux.Vars(r)["token"]
2✔
1096

2✔
1097
        // The token column is a unique key, so we expect only one result.
2✔
1098
        var dbCommitment db.ProjectCommitment
2✔
1099
        err := p.DB.SelectOne(&dbCommitment, findCommitmentByTransferToken, transferToken)
2✔
1100
        if errors.Is(err, sql.ErrNoRows) {
3✔
1101
                http.Error(w, "no matching commitment found.", http.StatusNotFound)
1✔
1102
                return
1✔
1103
        } else if respondwith.ObfuscatedErrorText(w, err) {
2✔
1104
                return
×
1105
        }
×
1106

1107
        var loc core.AZResourceLocation
1✔
1108
        err = p.DB.QueryRow(findAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
1✔
1109
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
1✔
1110
        if errors.Is(err, sql.ErrNoRows) {
1✔
1111
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
1112
                http.Error(w, "location data not found.", http.StatusNotFound)
×
1113
                return
×
1114
        } else if respondwith.ObfuscatedErrorText(w, err) {
1✔
1115
                return
×
1116
        }
×
1117

1118
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
1✔
1119
        if respondwith.ObfuscatedErrorText(w, err) {
1✔
1120
                return
×
1121
        }
×
1122
        serviceInfo, ok := maybeServiceInfo.Unpack()
1✔
1123
        if !ok {
1✔
1124
                http.Error(w, "service not found", http.StatusNotFound)
×
1125
                return
×
1126
        }
×
1127
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
1✔
1128
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token, resourceInfo.Unit)
1✔
1129
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
1✔
1130
}
1131

1132
// TransferCommitment handles POST /v1/domains/{domain_id}/projects/{project_id}/transfer-commitment/{id}?token={token}
1133
func (p *v1Provider) TransferCommitment(w http.ResponseWriter, r *http.Request) {
5✔
1134
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/transfer-commitment/:id")
5✔
1135
        token := p.CheckToken(r)
5✔
1136
        if !token.Require(w, "project:edit") {
5✔
1137
                return
×
1138
        }
×
1139
        transferToken := r.Header.Get("Transfer-Token")
5✔
1140
        if transferToken == "" {
6✔
1141
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
1✔
1142
                return
1✔
1143
        }
1✔
1144
        commitmentID := mux.Vars(r)["id"]
4✔
1145
        if commitmentID == "" {
4✔
1146
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
×
1147
                return
×
1148
        }
×
1149
        dbDomain := p.FindDomainFromRequest(w, r)
4✔
1150
        if dbDomain == nil {
4✔
1151
                return
×
1152
        }
×
1153
        targetProject := p.FindProjectFromRequest(w, r, dbDomain)
4✔
1154
        if targetProject == nil {
4✔
1155
                return
×
1156
        }
×
1157

1158
        // find commitment by transfer_token
1159
        var dbCommitment db.ProjectCommitment
4✔
1160
        err := p.DB.SelectOne(&dbCommitment, getCommitmentWithMatchingTransferTokenQuery, commitmentID, transferToken)
4✔
1161
        if errors.Is(err, sql.ErrNoRows) {
5✔
1162
                http.Error(w, "no matching commitment found", http.StatusNotFound)
1✔
1163
                return
1✔
1164
        } else if respondwith.ObfuscatedErrorText(w, err) {
4✔
1165
                return
×
1166
        }
×
1167

1168
        var loc core.AZResourceLocation
3✔
1169
        err = p.DB.QueryRow(findAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
3✔
1170
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
3✔
1171
        if errors.Is(err, sql.ErrNoRows) {
3✔
1172
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
1173
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
1174
                return
×
1175
        } else if respondwith.ObfuscatedErrorText(w, err) {
3✔
1176
                return
×
1177
        }
×
1178

1179
        // check that the target project allows commitments at all
1180
        var (
3✔
1181
                azResourceID              db.AZResourceID
3✔
1182
                resourceAllowsCommitments bool
3✔
1183
        )
3✔
1184
        err = p.DB.QueryRow(findAZResourceIDByLocationQuery, targetProject.ID, loc.ServiceType, loc.ResourceName, loc.AvailabilityZone).
3✔
1185
                Scan(&azResourceID, &resourceAllowsCommitments)
3✔
1186
        if respondwith.ObfuscatedErrorText(w, err) {
3✔
1187
                return
×
1188
        }
×
1189
        if !resourceAllowsCommitments {
3✔
1190
                msg := fmt.Sprintf("resource %s/%s is not enabled in the target project", loc.ServiceType, loc.ResourceName)
×
1191
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
1192
                return
×
1193
        }
×
1194
        _ = azResourceID // returned by the above query, but not used in this function
3✔
1195

3✔
1196
        // validate that we have enough committable capacity on the receiving side
3✔
1197
        tx, err := p.DB.Begin()
3✔
1198
        if respondwith.ObfuscatedErrorText(w, err) {
3✔
1199
                return
×
1200
        }
×
1201
        defer sqlext.RollbackUnlessCommitted(tx)
3✔
1202
        ok, err := datamodel.CanMoveExistingCommitment(dbCommitment.Amount, loc, dbCommitment.ProjectID, targetProject.ID, p.Cluster, tx)
3✔
1203
        if respondwith.ObfuscatedErrorText(w, err) {
3✔
1204
                return
×
1205
        }
×
1206
        if !ok {
4✔
1207
                http.Error(w, "not enough committable capacity on the receiving side", http.StatusConflict)
1✔
1208
                return
1✔
1209
        }
1✔
1210

1211
        dbCommitment.TransferStatus = ""
2✔
1212
        dbCommitment.TransferToken = None[string]()
2✔
1213
        dbCommitment.ProjectID = targetProject.ID
2✔
1214
        _, err = tx.Update(&dbCommitment)
2✔
1215
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
1216
                return
×
1217
        }
×
1218

1219
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
2✔
1220
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
1221
                return
×
1222
        }
×
1223
        serviceInfo, ok := maybeServiceInfo.Unpack()
2✔
1224
        if !ok {
2✔
1225
                http.Error(w, "service not found", http.StatusNotFound)
×
1226
                return
×
1227
        }
×
1228

1229
        err = tx.Commit()
2✔
1230
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
1231
                return
×
1232
        }
×
1233

1234
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
2✔
1235
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token, resourceInfo.Unit)
2✔
1236
        p.auditor.Record(audittools.Event{
2✔
1237
                Time:       p.timeNow(),
2✔
1238
                Request:    r,
2✔
1239
                User:       token,
2✔
1240
                ReasonCode: http.StatusAccepted,
2✔
1241
                Action:     cadf.UpdateAction,
2✔
1242
                Target: commitmentEventTarget{
2✔
1243
                        DomainID:    dbDomain.UUID,
2✔
1244
                        DomainName:  dbDomain.Name,
2✔
1245
                        ProjectID:   targetProject.UUID,
2✔
1246
                        ProjectName: targetProject.Name,
2✔
1247
                        Commitments: []limesresources.Commitment{c},
2✔
1248
                },
2✔
1249
        })
2✔
1250

2✔
1251
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
2✔
1252
}
1253

1254
// GetCommitmentConversion handles GET /v1/commitment-conversion/{service_type}/{resource_name}
1255
func (p *v1Provider) GetCommitmentConversions(w http.ResponseWriter, r *http.Request) {
2✔
1256
        httpapi.IdentifyEndpoint(r, "/v1/commitment-conversion/:service_type/:resource_name")
2✔
1257
        token := p.CheckToken(r)
2✔
1258
        if !token.Require(w, "cluster:show_basic") {
2✔
1259
                return
×
1260
        }
×
1261

1262
        // TODO v2 API: This endpoint should be project-scoped in order to make it
1263
        // easier to select the correct domain scope for the CommitmentBehavior.
1264
        forTokenScope := func(behavior core.CommitmentBehavior) core.ScopedCommitmentBehavior {
13✔
1265
                name := cmp.Or(token.ProjectScopeDomainName(), token.DomainScopeName(), "")
11✔
1266
                if name != "" {
22✔
1267
                        return behavior.ForDomain(name)
11✔
1268
                }
11✔
1269
                return behavior.ForCluster()
×
1270
        }
1271

1272
        // validate request
1273
        vars := mux.Vars(r)
2✔
1274
        serviceInfos, err := p.Cluster.AllServiceInfos()
2✔
1275
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
1276
                return
×
1277
        }
×
1278

1279
        nm := core.BuildResourceNameMapping(p.Cluster, serviceInfos)
2✔
1280
        sourceServiceType, sourceResourceName, exists := nm.MapFromV1API(
2✔
1281
                limes.ServiceType(vars["service_type"]),
2✔
1282
                limesresources.ResourceName(vars["resource_name"]),
2✔
1283
        )
2✔
1284
        if !exists {
2✔
1285
                msg := fmt.Sprintf("no such service and/or resource: %s/%s", vars["service_type"], vars["resource_name"])
×
1286
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
1287
                return
×
1288
        }
×
1289
        sourceBehavior := forTokenScope(p.Cluster.CommitmentBehaviorForResource(sourceServiceType, sourceResourceName))
2✔
1290

2✔
1291
        serviceInfo := core.InfoForService(serviceInfos, sourceServiceType)
2✔
1292
        sourceResInfo := core.InfoForResource(serviceInfo, sourceResourceName)
2✔
1293

2✔
1294
        // enumerate possible conversions
2✔
1295
        conversions := make([]limesresources.CommitmentConversionRule, 0)
2✔
1296
        if sourceBehavior.ConversionRule.IsSome() {
4✔
1297
                for _, targetServiceType := range slices.Sorted(maps.Keys(serviceInfos)) {
8✔
1298
                        for targetResourceName, targetResInfo := range serviceInfos[targetServiceType].Resources {
28✔
1299
                                if sourceServiceType == targetServiceType && sourceResourceName == targetResourceName {
24✔
1300
                                        continue
2✔
1301
                                }
1302
                                if sourceResInfo.Unit != targetResInfo.Unit {
31✔
1303
                                        continue
11✔
1304
                                }
1305

1306
                                targetBehavior := forTokenScope(p.Cluster.CommitmentBehaviorForResource(targetServiceType, targetResourceName))
9✔
1307
                                if rate, ok := sourceBehavior.GetConversionRateTo(targetBehavior).Unpack(); ok {
13✔
1308
                                        apiServiceType, apiResourceName, ok := nm.MapToV1API(targetServiceType, targetResourceName)
4✔
1309
                                        if ok {
8✔
1310
                                                conversions = append(conversions, limesresources.CommitmentConversionRule{
4✔
1311
                                                        FromAmount:     rate.FromAmount,
4✔
1312
                                                        ToAmount:       rate.ToAmount,
4✔
1313
                                                        TargetService:  apiServiceType,
4✔
1314
                                                        TargetResource: apiResourceName,
4✔
1315
                                                })
4✔
1316
                                        }
4✔
1317
                                }
1318
                        }
1319
                }
1320
        }
1321

1322
        // use a defined sorting to ensure deterministic behavior in tests
1323
        slices.SortFunc(conversions, func(lhs, rhs limesresources.CommitmentConversionRule) int {
5✔
1324
                result := strings.Compare(string(lhs.TargetService), string(rhs.TargetService))
3✔
1325
                if result != 0 {
5✔
1326
                        return result
2✔
1327
                }
2✔
1328
                return strings.Compare(string(lhs.TargetResource), string(rhs.TargetResource))
1✔
1329
        })
1330

1331
        respondwith.JSON(w, http.StatusOK, map[string]any{"conversions": conversions})
2✔
1332
}
1333

1334
// ConvertCommitment handles POST /v1/domains/{domain_id}/projects/{project_id}/commitments/{commitment_id}/convert
1335
func (p *v1Provider) ConvertCommitment(w http.ResponseWriter, r *http.Request) {
9✔
1336
        httpapi.IdentifyEndpoint(r, "/v1/domains/:domain_id/projects/:project_id/commitments/:commitment_id/convert")
9✔
1337
        token := p.CheckToken(r)
9✔
1338
        if !token.Require(w, "project:edit") {
9✔
1339
                return
×
1340
        }
×
1341
        commitmentID := mux.Vars(r)["commitment_id"]
9✔
1342
        if commitmentID == "" {
9✔
1343
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
×
1344
                return
×
1345
        }
×
1346
        dbDomain := p.FindDomainFromRequest(w, r)
9✔
1347
        if dbDomain == nil {
9✔
1348
                return
×
1349
        }
×
1350
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
9✔
1351
        if dbProject == nil {
9✔
1352
                return
×
1353
        }
×
1354

1355
        // section: sourceBehavior
1356
        var dbCommitment db.ProjectCommitment
9✔
1357
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, commitmentID, dbProject.ID)
9✔
1358
        if errors.Is(err, sql.ErrNoRows) {
10✔
1359
                http.Error(w, "no such commitment", http.StatusNotFound)
1✔
1360
                return
1✔
1361
        } else if respondwith.ObfuscatedErrorText(w, err) {
9✔
1362
                return
×
1363
        }
×
1364
        var sourceLoc core.AZResourceLocation
8✔
1365
        err = p.DB.QueryRow(findAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
8✔
1366
                Scan(&sourceLoc.ServiceType, &sourceLoc.ResourceName, &sourceLoc.AvailabilityZone)
8✔
1367
        if errors.Is(err, sql.ErrNoRows) {
8✔
1368
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
1369
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
1370
                return
×
1371
        } else if respondwith.ObfuscatedErrorText(w, err) {
8✔
1372
                return
×
1373
        }
×
1374
        sourceBehavior := p.Cluster.CommitmentBehaviorForResource(sourceLoc.ServiceType, sourceLoc.ResourceName).ForDomain(dbDomain.Name)
8✔
1375

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

1417
        // section: conversion
1418
        if req.SourceAmount > dbCommitment.Amount {
6✔
1419
                msg := fmt.Sprintf("unprocessable source amount. provided: %v, commitment: %v", req.SourceAmount, dbCommitment.Amount)
×
1420
                http.Error(w, msg, http.StatusConflict)
×
1421
                return
×
1422
        }
×
1423
        conversionAmount := (req.SourceAmount / rate.FromAmount) * rate.ToAmount
6✔
1424
        remainderAmount := req.SourceAmount % rate.FromAmount
6✔
1425
        if remainderAmount > 0 {
8✔
1426
                msg := fmt.Sprintf("amount: %v does not fit into conversion rate of: %v", req.SourceAmount, rate.FromAmount)
2✔
1427
                http.Error(w, msg, http.StatusConflict)
2✔
1428
                return
2✔
1429
        }
2✔
1430
        if conversionAmount != req.TargetAmount {
5✔
1431
                msg := fmt.Sprintf("conversion mismatch. provided: %v, calculated: %v", req.TargetAmount, conversionAmount)
1✔
1432
                http.Error(w, msg, http.StatusConflict)
1✔
1433
                return
1✔
1434
        }
1✔
1435

1436
        tx, err := p.DB.Begin()
3✔
1437
        if respondwith.ObfuscatedErrorText(w, err) {
3✔
1438
                return
×
1439
        }
×
1440
        defer sqlext.RollbackUnlessCommitted(tx)
3✔
1441

3✔
1442
        var (
3✔
1443
                targetAZResourceID        db.AZResourceID
3✔
1444
                resourceAllowsCommitments bool
3✔
1445
        )
3✔
1446
        err = p.DB.QueryRow(findAZResourceIDByLocationQuery, dbProject.ID, targetServiceType, targetResourceName, sourceLoc.AvailabilityZone).
3✔
1447
                Scan(&targetAZResourceID, &resourceAllowsCommitments)
3✔
1448
        if respondwith.ObfuscatedErrorText(w, err) {
3✔
1449
                return
×
1450
        }
×
1451
        // defense in depth. ServiceType and ResourceName of source and target are already checked. Here it's possible to explicitly check the ID's.
1452
        if dbCommitment.AZResourceID == targetAZResourceID {
3✔
1453
                http.Error(w, "conversion attempt to the same resource.", http.StatusConflict)
×
1454
                return
×
1455
        }
×
1456
        if !resourceAllowsCommitments {
3✔
1457
                msg := fmt.Sprintf("resource %s/%s is not enabled in this project", targetServiceType, targetResourceName)
×
1458
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
1459
                return
×
1460
        }
×
1461
        targetLoc := core.AZResourceLocation{
3✔
1462
                ServiceType:      targetServiceType,
3✔
1463
                ResourceName:     targetResourceName,
3✔
1464
                AvailabilityZone: sourceLoc.AvailabilityZone,
3✔
1465
        }
3✔
1466
        // The commitment at the source resource was already confirmed and checked.
3✔
1467
        // Therefore only the addition to the target resource has to be checked against.
3✔
1468
        if dbCommitment.ConfirmedAt.IsSome() {
5✔
1469
                ok, err := datamodel.CanConfirmNewCommitment(targetLoc, dbProject.ID, conversionAmount, p.Cluster, p.DB)
2✔
1470
                if respondwith.ObfuscatedErrorText(w, err) {
2✔
1471
                        return
×
1472
                }
×
1473
                if !ok {
3✔
1474
                        http.Error(w, "not enough capacity to confirm the commitment", http.StatusUnprocessableEntity)
1✔
1475
                        return
1✔
1476
                }
1✔
1477
        }
1478

1479
        auditEvent := commitmentEventTarget{
2✔
1480
                DomainID:    dbDomain.UUID,
2✔
1481
                DomainName:  dbDomain.Name,
2✔
1482
                ProjectID:   dbProject.UUID,
2✔
1483
                ProjectName: dbProject.Name,
2✔
1484
        }
2✔
1485

2✔
1486
        var (
2✔
1487
                relatedCommitmentIDs   []db.ProjectCommitmentID
2✔
1488
                relatedCommitmentUUIDs []db.ProjectCommitmentUUID
2✔
1489
        )
2✔
1490
        remainingAmount := dbCommitment.Amount - req.SourceAmount
2✔
1491
        serviceInfo := core.InfoForService(serviceInfos, sourceLoc.ServiceType)
2✔
1492
        resourceInfo := core.InfoForResource(serviceInfo, sourceLoc.ResourceName)
2✔
1493
        if remainingAmount > 0 {
3✔
1494
                remainingCommitment, err := p.buildSplitCommitment(dbCommitment, remainingAmount)
1✔
1495
                if respondwith.ObfuscatedErrorText(w, err) {
1✔
1496
                        return
×
1497
                }
×
1498
                relatedCommitmentIDs = append(relatedCommitmentIDs, remainingCommitment.ID)
1✔
1499
                relatedCommitmentUUIDs = append(relatedCommitmentUUIDs, remainingCommitment.UUID)
1✔
1500
                err = tx.Insert(&remainingCommitment)
1✔
1501
                if respondwith.ObfuscatedErrorText(w, err) {
1✔
1502
                        return
×
1503
                }
×
1504
                auditEvent.Commitments = append(auditEvent.Commitments,
1✔
1505
                        p.convertCommitmentToDisplayForm(remainingCommitment, sourceLoc, token, resourceInfo.Unit),
1✔
1506
                )
1✔
1507
        }
1508

1509
        convertedCommitment, err := p.buildConvertedCommitment(dbCommitment, targetAZResourceID, conversionAmount)
2✔
1510
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
1511
                return
×
1512
        }
×
1513
        relatedCommitmentIDs = append(relatedCommitmentIDs, convertedCommitment.ID)
2✔
1514
        relatedCommitmentUUIDs = append(relatedCommitmentUUIDs, convertedCommitment.UUID)
2✔
1515
        err = tx.Insert(&convertedCommitment)
2✔
1516
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
1517
                return
×
1518
        }
×
1519

1520
        // supersede the original commitment
1521
        now := p.timeNow()
2✔
1522
        supersedeContext := db.CommitmentWorkflowContext{
2✔
1523
                Reason:                 db.CommitmentReasonConvert,
2✔
1524
                RelatedCommitmentIDs:   relatedCommitmentIDs,
2✔
1525
                RelatedCommitmentUUIDs: relatedCommitmentUUIDs,
2✔
1526
        }
2✔
1527
        buf, err := json.Marshal(supersedeContext)
2✔
1528
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
1529
                return
×
1530
        }
×
1531
        dbCommitment.State = db.CommitmentStateSuperseded
2✔
1532
        dbCommitment.SupersededAt = Some(now)
2✔
1533
        dbCommitment.SupersedeContextJSON = Some(json.RawMessage(buf))
2✔
1534
        _, err = tx.Update(&dbCommitment)
2✔
1535
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
1536
                return
×
1537
        }
×
1538

1539
        err = tx.Commit()
2✔
1540
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
1541
                return
×
1542
        }
×
1543

1544
        c := p.convertCommitmentToDisplayForm(convertedCommitment, targetLoc, token, resourceInfo.Unit)
2✔
1545
        auditEvent.Commitments = append([]limesresources.Commitment{c}, auditEvent.Commitments...)
2✔
1546
        auditEvent.WorkflowContext = Some(db.CommitmentWorkflowContext{
2✔
1547
                Reason:                 db.CommitmentReasonSplit,
2✔
1548
                RelatedCommitmentIDs:   []db.ProjectCommitmentID{dbCommitment.ID},
2✔
1549
                RelatedCommitmentUUIDs: []db.ProjectCommitmentUUID{dbCommitment.UUID},
2✔
1550
        })
2✔
1551
        p.auditor.Record(audittools.Event{
2✔
1552
                Time:       p.timeNow(),
2✔
1553
                Request:    r,
2✔
1554
                User:       token,
2✔
1555
                ReasonCode: http.StatusAccepted,
2✔
1556
                Action:     cadf.UpdateAction,
2✔
1557
                Target:     auditEvent,
2✔
1558
        })
2✔
1559

2✔
1560
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
2✔
1561
}
1562

1563
// ExtendCommitmentDuration handles POST /v1/domains/{domain_id}/projects/{project_id}/commitments/{commitment_id}/update-duration
1564
func (p *v1Provider) UpdateCommitmentDuration(w http.ResponseWriter, r *http.Request) {
6✔
1565
        httpapi.IdentifyEndpoint(r, "/v1/domains/:domain_id/projects/:project_id/commitments/:commitment_id/update-duration")
6✔
1566
        token := p.CheckToken(r)
6✔
1567
        if !token.Require(w, "project:edit") {
6✔
1568
                return
×
1569
        }
×
1570
        commitmentID := mux.Vars(r)["commitment_id"]
6✔
1571
        if commitmentID == "" {
6✔
1572
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
×
1573
                return
×
1574
        }
×
1575
        dbDomain := p.FindDomainFromRequest(w, r)
6✔
1576
        if dbDomain == nil {
6✔
1577
                return
×
1578
        }
×
1579
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
6✔
1580
        if dbProject == nil {
6✔
1581
                return
×
1582
        }
×
1583
        var Request struct {
6✔
1584
                Duration limesresources.CommitmentDuration `json:"duration"`
6✔
1585
        }
6✔
1586
        req := Request
6✔
1587
        if !RequireJSON(w, r, &req) {
6✔
1588
                return
×
1589
        }
×
1590

1591
        var dbCommitment db.ProjectCommitment
6✔
1592
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, commitmentID, dbProject.ID)
6✔
1593
        if errors.Is(err, sql.ErrNoRows) {
6✔
1594
                http.Error(w, "no such commitment", http.StatusNotFound)
×
1595
                return
×
1596
        } else if respondwith.ObfuscatedErrorText(w, err) {
6✔
1597
                return
×
1598
        }
×
1599

1600
        now := p.timeNow()
6✔
1601
        if dbCommitment.ExpiresAt.Before(now) || dbCommitment.ExpiresAt.Equal(now) {
7✔
1602
                http.Error(w, "unable to process expired commitment", http.StatusForbidden)
1✔
1603
                return
1✔
1604
        }
1✔
1605

1606
        if dbCommitment.State == db.CommitmentStateSuperseded {
6✔
1607
                msg := fmt.Sprintf("unable to operate on commitment with a state of %s", dbCommitment.State)
1✔
1608
                http.Error(w, msg, http.StatusForbidden)
1✔
1609
                return
1✔
1610
        }
1✔
1611

1612
        var loc core.AZResourceLocation
4✔
1613
        err = p.DB.QueryRow(findAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
4✔
1614
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
4✔
1615
        if errors.Is(err, sql.ErrNoRows) {
4✔
1616
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
1617
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
1618
                return
×
1619
        } else if respondwith.ObfuscatedErrorText(w, err) {
4✔
1620
                return
×
1621
        }
×
1622
        behavior := p.Cluster.CommitmentBehaviorForResource(loc.ServiceType, loc.ResourceName).ForDomain(dbDomain.Name)
4✔
1623
        if !slices.Contains(behavior.Durations, req.Duration) {
5✔
1624
                msg := fmt.Sprintf("provided duration: %s does not match the config %v", req.Duration, behavior.Durations)
1✔
1625
                http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
1626
                return
1✔
1627
        }
1✔
1628

1629
        newExpiresAt := req.Duration.AddTo(dbCommitment.ConfirmBy.UnwrapOr(dbCommitment.CreatedAt))
3✔
1630
        if newExpiresAt.Before(dbCommitment.ExpiresAt) {
4✔
1631
                msg := fmt.Sprintf("duration change from %s to %s forbidden", dbCommitment.Duration, req.Duration)
1✔
1632
                http.Error(w, msg, http.StatusForbidden)
1✔
1633
                return
1✔
1634
        }
1✔
1635

1636
        dbCommitment.Duration = req.Duration
2✔
1637
        dbCommitment.ExpiresAt = newExpiresAt
2✔
1638
        _, err = p.DB.Update(&dbCommitment)
2✔
1639
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
1640
                return
×
1641
        }
×
1642

1643
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
2✔
1644
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
1645
                return
×
1646
        }
×
1647
        serviceInfo, ok := maybeServiceInfo.Unpack()
2✔
1648
        if !ok {
2✔
1649
                http.Error(w, "service not found", http.StatusNotFound)
×
1650
                return
×
1651
        }
×
1652
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
2✔
1653
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token, resourceInfo.Unit)
2✔
1654
        p.auditor.Record(audittools.Event{
2✔
1655
                Time:       p.timeNow(),
2✔
1656
                Request:    r,
2✔
1657
                User:       token,
2✔
1658
                ReasonCode: http.StatusOK,
2✔
1659
                Action:     cadf.UpdateAction,
2✔
1660
                Target: commitmentEventTarget{
2✔
1661
                        DomainID:    dbDomain.UUID,
2✔
1662
                        DomainName:  dbDomain.Name,
2✔
1663
                        ProjectID:   dbProject.UUID,
2✔
1664
                        ProjectName: dbProject.Name,
2✔
1665
                        Commitments: []limesresources.Commitment{c},
2✔
1666
                },
2✔
1667
        })
2✔
1668

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

© 2025 Coveralls, Inc