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

sapcc / limes / 15820266830

23 Jun 2025 09:14AM UTC coverage: 78.407% (-0.7%) from 79.155%
15820266830

Pull #725

github

wagnerd3
utilize ServiceInfo from database for collect-startup and serve-tasks
Pull Request #725: utilize ServiceInfo from database for collect-startup and serve-tasks

652 of 834 new or added lines in 31 files covered. (78.18%)

8 existing lines in 2 files now uncovered.

6674 of 8512 relevant lines covered (78.41%)

54.98 hits per line

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

77.46
/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 pc
43
                  JOIN project_az_resources par ON pc.az_resource_id = par.id
44
                  JOIN project_resources pr ON par.resource_id = pr.id {{AND pr.name = $resource_name}}
45
                  JOIN project_services ps ON pr.service_id = ps.id {{AND ps.type = $service_type}}
46
                 WHERE %s AND pc.state NOT IN ('superseded', 'expired')
47
                 ORDER BY pc.id
48
        `)
49

50
        getProjectAZResourceLocationsQuery = sqlext.SimplifyWhitespace(`
51
                SELECT par.id, ps.type, pr.name, par.az
52
                  FROM project_az_resources par
53
                  JOIN project_resources pr ON par.resource_id = pr.id {{AND pr.name = $resource_name}}
54
                  JOIN project_services ps ON pr.service_id = ps.id {{AND ps.type = $service_type}}
55
                 WHERE %s
56
        `)
57

58
        findProjectCommitmentByIDQuery = sqlext.SimplifyWhitespace(`
59
                SELECT pc.*
60
                  FROM project_commitments pc
61
                  JOIN project_az_resources par ON pc.az_resource_id = par.id
62
                  JOIN project_resources pr ON par.resource_id = pr.id
63
                  JOIN project_services ps ON pr.service_id = ps.id
64
                 WHERE pc.id = $1 AND ps.project_id = $2
65
        `)
66

67
        // NOTE: The third output column is `resourceAllowsCommitments`.
68
        findProjectAZResourceIDByLocationQuery = sqlext.SimplifyWhitespace(`
69
                SELECT pr.id, par.id, pr.forbidden IS NOT TRUE
70
                  FROM project_az_resources par
71
                  JOIN project_resources pr ON par.resource_id = pr.id
72
                  JOIN project_services ps ON pr.service_id = ps.id
73
                 WHERE ps.project_id = $1 AND ps.type = $2 AND pr.name = $3 AND par.az = $4
74
        `)
75

76
        findProjectAZResourceLocationByIDQuery = sqlext.SimplifyWhitespace(`
77
                SELECT ps.type, pr.name, par.az
78
                  FROM project_az_resources par
79
                  JOIN project_resources pr ON par.resource_id = pr.id
80
                  JOIN project_services ps ON pr.service_id = ps.id
81
                 WHERE par.id = $1
82
        `)
83
        getCommitmentWithMatchingTransferTokenQuery = sqlext.SimplifyWhitespace(`
84
                SELECT * FROM project_commitments WHERE id = $1 AND transfer_token = $2
85
        `)
86
        findCommitmentByTransferToken = sqlext.SimplifyWhitespace(`
87
                SELECT * FROM project_commitments WHERE transfer_token = $1
88
        `)
89
        findTargetAZResourceIDBySourceIDQuery = sqlext.SimplifyWhitespace(`
90
                WITH source as (
91
                SELECT pr.id AS resource_id, ps.type, pr.name, par.az
92
                  FROM project_az_resources as par
93
                  JOIN project_resources pr ON par.resource_id = pr.id
94
                  JOIN project_services ps ON pr.service_id = ps.id
95
                 WHERE par.id = $1
96
                )
97
                SELECT s.resource_id, pr.id, par.id
98
                  FROM project_az_resources as par
99
                  JOIN project_resources pr ON par.resource_id = pr.id
100
                  JOIN project_services ps ON pr.service_id = ps.id
101
                  JOIN source s ON ps.type = s.type AND pr.name = s.name AND par.az = s.az
102
                 WHERE ps.project_id = $2
103
        `)
104
        findTargetAZResourceByTargetProjectQuery = sqlext.SimplifyWhitespace(`
105
                SELECT pr.id, par.id
106
                  FROM project_az_resources par
107
                  JOIN project_resources pr ON par.resource_id = pr.id
108
                  JOIN project_services ps ON pr.service_id = ps.id
109
                 WHERE ps.project_id = $1 AND ps.type = $2 AND pr.name = $3 AND par.az = $4
110
        `)
111
)
112

113
// GetProjectCommitments handles GET /v1/domains/:domain_id/projects/:project_id/commitments.
114
func (p *v1Provider) GetProjectCommitments(w http.ResponseWriter, r *http.Request) {
15✔
115
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments")
15✔
116
        token := p.CheckToken(r)
15✔
117
        if !token.Require(w, "project:show") {
16✔
118
                return
1✔
119
        }
1✔
120
        dbDomain := p.FindDomainFromRequest(w, r)
14✔
121
        if dbDomain == nil {
15✔
122
                return
1✔
123
        }
1✔
124
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
13✔
125
        if dbProject == nil {
14✔
126
                return
1✔
127
        }
1✔
128
        serviceInfos, err := p.Cluster.AllServiceInfos()
12✔
129
        if respondwith.ErrorText(w, err) {
12✔
NEW
130
                return
×
NEW
131
        }
×
132

133
        // enumerate project AZ resources
134
        filter := reports.ReadFilter(r, p.Cluster, serviceInfos)
12✔
135
        queryStr, joinArgs := filter.PrepareQuery(getProjectAZResourceLocationsQuery)
12✔
136
        whereStr, whereArgs := db.BuildSimpleWhereClause(map[string]any{"ps.project_id": dbProject.ID}, len(joinArgs))
12✔
137
        azResourceLocationsByID := make(map[db.ProjectAZResourceID]core.AZResourceLocation)
12✔
138
        err = sqlext.ForeachRow(p.DB, fmt.Sprintf(queryStr, whereStr), append(joinArgs, whereArgs...), func(rows *sql.Rows) error {
137✔
139
                var (
125✔
140
                        id  db.ProjectAZResourceID
125✔
141
                        loc core.AZResourceLocation
125✔
142
                )
125✔
143
                err := rows.Scan(&id, &loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
125✔
144
                if err != nil {
125✔
145
                        return err
×
146
                }
×
147
                // this check is defense in depth (the DB should be consistent with our config)
148
                if core.HasResource(serviceInfos, loc.ServiceType, loc.ResourceName) {
223✔
149
                        azResourceLocationsByID[id] = loc
98✔
150
                }
98✔
151
                return nil
125✔
152
        })
153
        if respondwith.ErrorText(w, err) {
12✔
154
                return
×
155
        }
×
156

157
        // enumerate relevant project commitments
158
        queryStr, joinArgs = filter.PrepareQuery(getProjectCommitmentsQuery)
12✔
159
        whereStr, whereArgs = db.BuildSimpleWhereClause(map[string]any{"ps.project_id": dbProject.ID}, len(joinArgs))
12✔
160
        var dbCommitments []db.ProjectCommitment
12✔
161
        _, err = p.DB.Select(&dbCommitments, fmt.Sprintf(queryStr, whereStr), append(joinArgs, whereArgs...)...)
12✔
162
        if respondwith.ErrorText(w, err) {
12✔
163
                return
×
164
        }
×
165

166
        // render response
167
        result := make([]limesresources.Commitment, 0, len(dbCommitments))
12✔
168
        for _, c := range dbCommitments {
26✔
169
                loc, exists := azResourceLocationsByID[c.AZResourceID]
14✔
170
                if !exists {
14✔
171
                        // defense in depth (the DB should not change that much between those two queries above)
×
172
                        continue
×
173
                }
174
                serviceInfo := core.InfoForService(serviceInfos, loc.ServiceType)
14✔
175
                resInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
14✔
176
                result = append(result, p.convertCommitmentToDisplayForm(c, loc, token, resInfo.Unit))
14✔
177
        }
178

179
        respondwith.JSON(w, http.StatusOK, map[string]any{"commitments": result})
12✔
180
}
181

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

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

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

263
        loc := core.AZResourceLocation{
35✔
264
                ServiceType:      dbServiceType,
35✔
265
                ResourceName:     dbResourceName,
35✔
266
                AvailabilityZone: req.AvailabilityZone,
35✔
267
        }
35✔
268
        return &req, &loc, &behavior
35✔
269
}
270

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

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

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

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

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

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

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

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

25✔
380
        // prepare commitment
25✔
381
        confirmBy := options.Map(options.FromPointer(req.ConfirmBy), fromUnixEncodedTime)
25✔
382
        creationContext := db.CommitmentWorkflowContext{Reason: db.CommitmentReasonCreate}
25✔
383
        buf, err := json.Marshal(creationContext)
25✔
384
        if respondwith.ErrorText(w, err) {
25✔
385
                return
×
386
        }
×
387
        dbCommitment := db.ProjectCommitment{
25✔
388
                UUID:                p.generateProjectCommitmentUUID(),
25✔
389
                AZResourceID:        azResourceID,
25✔
390
                Amount:              req.Amount,
25✔
391
                Duration:            req.Duration,
25✔
392
                CreatedAt:           now,
25✔
393
                CreatorUUID:         token.UserUUID(),
25✔
394
                CreatorName:         fmt.Sprintf("%s@%s", token.UserName(), token.UserDomainName()),
25✔
395
                ConfirmBy:           confirmBy,
25✔
396
                ConfirmedAt:         None[time.Time](), // may be set below
25✔
397
                ExpiresAt:           req.Duration.AddTo(confirmBy.UnwrapOr(now)),
25✔
398
                CreationContextJSON: json.RawMessage(buf),
25✔
399
        }
25✔
400
        if req.NotifyOnConfirm && req.ConfirmBy == nil {
26✔
401
                http.Error(w, "notification on confirm cannot be set for commitments with immediate confirmation", http.StatusConflict)
1✔
402
                return
1✔
403
        }
1✔
404
        dbCommitment.NotifyOnConfirm = req.NotifyOnConfirm
24✔
405

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

422
        // create commitment
423
        err = tx.Insert(&dbCommitment)
24✔
424
        if respondwith.ErrorText(w, err) {
24✔
425
                return
×
426
        }
×
427
        err = tx.Commit()
24✔
428
        if respondwith.ErrorText(w, err) {
24✔
429
                return
×
430
        }
×
431
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
24✔
432
        if respondwith.ErrorText(w, err) {
24✔
NEW
433
                return
×
NEW
434
        }
×
435
        serviceInfo, ok := maybeServiceInfo.Unpack()
24✔
436
        if !ok {
24✔
NEW
437
                http.Error(w, "service not found", http.StatusNotFound)
×
NEW
438
                return
×
NEW
439
        }
×
440
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
24✔
441
        commitment := p.convertCommitmentToDisplayForm(dbCommitment, *loc, token, resourceInfo.Unit)
24✔
442
        p.auditor.Record(audittools.Event{
24✔
443
                Time:       now,
24✔
444
                Request:    r,
24✔
445
                User:       token,
24✔
446
                ReasonCode: http.StatusCreated,
24✔
447
                Action:     cadf.CreateAction,
24✔
448
                Target: commitmentEventTarget{
24✔
449
                        DomainID:        dbDomain.UUID,
24✔
450
                        DomainName:      dbDomain.Name,
24✔
451
                        ProjectID:       dbProject.UUID,
24✔
452
                        ProjectName:     dbProject.Name,
24✔
453
                        Commitments:     []limesresources.Commitment{commitment},
24✔
454
                        WorkflowContext: Some(creationContext),
24✔
455
                },
24✔
456
        })
24✔
457

24✔
458
        // if the commitment is immediately confirmed, trigger a capacity scrape in
24✔
459
        // order to ApplyComputedProjectQuotas based on the new commitment
24✔
460
        if dbCommitment.ConfirmedAt.IsSome() {
42✔
461
                _, err := p.DB.Exec(`UPDATE cluster_services SET next_scrape_at = $1 WHERE type = $2`, now, loc.ServiceType)
18✔
462
                if respondwith.ErrorText(w, err) {
18✔
463
                        return
×
464
                }
×
465
        }
466

467
        // display the possibly confirmed commitment to the user
468
        err = p.DB.SelectOne(&dbCommitment, `SELECT * FROM project_commitments WHERE id = $1`, dbCommitment.ID)
24✔
469
        if respondwith.ErrorText(w, err) {
24✔
470
                return
×
471
        }
×
472

473
        respondwith.JSON(w, http.StatusCreated, map[string]any{"commitment": commitment})
24✔
474
}
475

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

503
        // Load commitments
504
        dbCommitments := make([]db.ProjectCommitment, len(commitmentIDs))
8✔
505
        commitmentUUIDs := make([]db.ProjectCommitmentUUID, len(commitmentIDs))
8✔
506
        for i, commitmentID := range commitmentIDs {
24✔
507
                err := p.DB.SelectOne(&dbCommitments[i], findProjectCommitmentByIDQuery, commitmentID, dbProject.ID)
16✔
508
                if errors.Is(err, sql.ErrNoRows) {
17✔
509
                        http.Error(w, "no such commitment", http.StatusNotFound)
1✔
510
                        return
1✔
511
                } else if respondwith.ErrorText(w, err) {
16✔
512
                        return
×
513
                }
×
514
                commitmentUUIDs[i] = dbCommitments[i].UUID
15✔
515
        }
516

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

530
        var loc core.AZResourceLocation
1✔
531
        err := p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, azResourceID).
1✔
532
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
1✔
533
        if errors.Is(err, sql.ErrNoRows) {
1✔
534
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
535
                return
×
536
        } else if respondwith.ErrorText(w, err) {
1✔
537
                return
×
538
        }
×
539

540
        // Start transaction for creating new commitment and marking merged commitments as superseded
541
        tx, err := p.DB.Begin()
1✔
542
        if respondwith.ErrorText(w, err) {
1✔
543
                return
×
544
        }
×
545
        defer sqlext.RollbackUnlessCommitted(tx)
1✔
546

1✔
547
        // Create merged template
1✔
548
        now := p.timeNow()
1✔
549
        dbMergedCommitment := db.ProjectCommitment{
1✔
550
                UUID:         p.generateProjectCommitmentUUID(),
1✔
551
                AZResourceID: azResourceID,
1✔
552
                Amount:       0,                                   // overwritten below
1✔
553
                Duration:     limesresources.CommitmentDuration{}, // overwritten below
1✔
554
                CreatedAt:    now,
1✔
555
                CreatorUUID:  token.UserUUID(),
1✔
556
                CreatorName:  fmt.Sprintf("%s@%s", token.UserName(), token.UserDomainName()),
1✔
557
                ConfirmedAt:  Some(now),
1✔
558
                ExpiresAt:    time.Time{}, // overwritten below
1✔
559
                State:        db.CommitmentStateActive,
1✔
560
        }
1✔
561

1✔
562
        // Fill amount and latest expiration date
1✔
563
        for _, dbCommitment := range dbCommitments {
3✔
564
                dbMergedCommitment.Amount += dbCommitment.Amount
2✔
565
                if dbCommitment.ExpiresAt.After(dbMergedCommitment.ExpiresAt) {
4✔
566
                        dbMergedCommitment.ExpiresAt = dbCommitment.ExpiresAt
2✔
567
                        dbMergedCommitment.Duration = dbCommitment.Duration
2✔
568
                }
2✔
569
        }
570

571
        // Fill workflow context
572
        creationContext := db.CommitmentWorkflowContext{
1✔
573
                Reason:                 db.CommitmentReasonMerge,
1✔
574
                RelatedCommitmentIDs:   commitmentIDs,
1✔
575
                RelatedCommitmentUUIDs: commitmentUUIDs,
1✔
576
        }
1✔
577
        buf, err := json.Marshal(creationContext)
1✔
578
        if respondwith.ErrorText(w, err) {
1✔
579
                return
×
580
        }
×
581
        dbMergedCommitment.CreationContextJSON = json.RawMessage(buf)
1✔
582

1✔
583
        // Insert into database
1✔
584
        err = tx.Insert(&dbMergedCommitment)
1✔
585
        if respondwith.ErrorText(w, err) {
1✔
586
                return
×
587
        }
×
588

589
        // Mark merged commits as superseded
590
        supersedeContext := db.CommitmentWorkflowContext{
1✔
591
                Reason:                 db.CommitmentReasonMerge,
1✔
592
                RelatedCommitmentIDs:   []db.ProjectCommitmentID{dbMergedCommitment.ID},
1✔
593
                RelatedCommitmentUUIDs: []db.ProjectCommitmentUUID{dbMergedCommitment.UUID},
1✔
594
        }
1✔
595
        buf, err = json.Marshal(supersedeContext)
1✔
596
        if respondwith.ErrorText(w, err) {
1✔
597
                return
×
598
        }
×
599
        for _, dbCommitment := range dbCommitments {
3✔
600
                dbCommitment.SupersededAt = Some(now)
2✔
601
                dbCommitment.SupersedeContextJSON = Some(json.RawMessage(buf))
2✔
602
                dbCommitment.State = db.CommitmentStateSuperseded
2✔
603
                _, err = tx.Update(&dbCommitment)
2✔
604
                if respondwith.ErrorText(w, err) {
2✔
605
                        return
×
606
                }
×
607
        }
608

609
        err = tx.Commit()
1✔
610
        if respondwith.ErrorText(w, err) {
1✔
611
                return
×
612
        }
×
613

614
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
1✔
615
        if respondwith.ErrorText(w, err) {
1✔
NEW
616
                return
×
NEW
617
        }
×
618
        serviceInfo, ok := maybeServiceInfo.Unpack()
1✔
619
        if !ok {
1✔
NEW
620
                http.Error(w, "service not found", http.StatusNotFound)
×
NEW
621
                return
×
NEW
622
        }
×
623
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
1✔
624

1✔
625
        c := p.convertCommitmentToDisplayForm(dbMergedCommitment, loc, token, resourceInfo.Unit)
1✔
626
        auditEvent := commitmentEventTarget{
1✔
627
                DomainID:        dbDomain.UUID,
1✔
628
                DomainName:      dbDomain.Name,
1✔
629
                ProjectID:       dbProject.UUID,
1✔
630
                ProjectName:     dbProject.Name,
1✔
631
                Commitments:     []limesresources.Commitment{c},
1✔
632
                WorkflowContext: Some(creationContext),
1✔
633
        }
1✔
634
        p.auditor.Record(audittools.Event{
1✔
635
                Time:       p.timeNow(),
1✔
636
                Request:    r,
1✔
637
                User:       token,
1✔
638
                ReasonCode: http.StatusAccepted,
1✔
639
                Action:     cadf.UpdateAction,
1✔
640
                Target:     auditEvent,
1✔
641
        })
1✔
642

1✔
643
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
1✔
644
}
645

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

649
// RenewProjectCommitments handles POST /v1/domains/:domain_id/projects/:project_id/commitments/:id/renew.
650
func (p *v1Provider) RenewProjectCommitments(w http.ResponseWriter, r *http.Request) {
6✔
651
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/:id/renew")
6✔
652
        token := p.CheckToken(r)
6✔
653
        if !token.Require(w, "project:edit") {
6✔
654
                return
×
655
        }
×
656
        dbDomain := p.FindDomainFromRequest(w, r)
6✔
657
        if dbDomain == nil {
6✔
658
                return
×
659
        }
×
660
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
6✔
661
        if dbProject == nil {
6✔
662
                return
×
663
        }
×
664

665
        // Load commitment
666
        var dbCommitment db.ProjectCommitment
6✔
667
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, mux.Vars(r)["id"], dbProject.ID)
6✔
668
        if errors.Is(err, sql.ErrNoRows) {
6✔
669
                http.Error(w, "no such commitment", http.StatusNotFound)
×
670
                return
×
671
        } else if respondwith.ErrorText(w, err) {
6✔
672
                return
×
673
        }
×
674
        now := p.timeNow()
6✔
675

6✔
676
        // Check if commitment can be renewed
6✔
677
        var errs errext.ErrorSet
6✔
678
        if dbCommitment.State != db.CommitmentStateActive {
7✔
679
                errs.Addf("invalid state %q", dbCommitment.State)
1✔
680
        } else if now.After(dbCommitment.ExpiresAt) {
7✔
681
                errs.Addf("invalid state %q", db.CommitmentStateExpired)
1✔
682
        }
1✔
683
        if now.Before(dbCommitment.ExpiresAt.Add(-commitmentRenewalPeriod)) {
7✔
684
                errs.Addf("renewal attempt too early")
1✔
685
        }
1✔
686
        if dbCommitment.RenewContextJSON.IsSome() {
7✔
687
                errs.Addf("already renewed")
1✔
688
        }
1✔
689

690
        if !errs.IsEmpty() {
10✔
691
                msg := "cannot renew this commitment: " + errs.Join(", ")
4✔
692
                http.Error(w, msg, http.StatusConflict)
4✔
693
                return
4✔
694
        }
4✔
695

696
        // Create renewed commitment
697
        tx, err := p.DB.Begin()
2✔
698
        if respondwith.ErrorText(w, err) {
2✔
699
                return
×
700
        }
×
701
        defer sqlext.RollbackUnlessCommitted(tx)
2✔
702

2✔
703
        var loc core.AZResourceLocation
2✔
704
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
2✔
705
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
2✔
706
        if errors.Is(err, sql.ErrNoRows) {
2✔
707
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
708
                return
×
709
        } else if respondwith.ErrorText(w, err) {
2✔
710
                return
×
711
        }
×
712

713
        creationContext := db.CommitmentWorkflowContext{
2✔
714
                Reason:                 db.CommitmentReasonRenew,
2✔
715
                RelatedCommitmentIDs:   []db.ProjectCommitmentID{dbCommitment.ID},
2✔
716
                RelatedCommitmentUUIDs: []db.ProjectCommitmentUUID{dbCommitment.UUID},
2✔
717
        }
2✔
718
        buf, err := json.Marshal(creationContext)
2✔
719
        if respondwith.ErrorText(w, err) {
2✔
720
                return
×
721
        }
×
722
        dbRenewedCommitment := db.ProjectCommitment{
2✔
723
                UUID:                p.generateProjectCommitmentUUID(),
2✔
724
                AZResourceID:        dbCommitment.AZResourceID,
2✔
725
                Amount:              dbCommitment.Amount,
2✔
726
                Duration:            dbCommitment.Duration,
2✔
727
                CreatedAt:           now,
2✔
728
                CreatorUUID:         token.UserUUID(),
2✔
729
                CreatorName:         fmt.Sprintf("%s@%s", token.UserName(), token.UserDomainName()),
2✔
730
                ConfirmBy:           Some(dbCommitment.ExpiresAt),
2✔
731
                ExpiresAt:           dbCommitment.Duration.AddTo(dbCommitment.ExpiresAt),
2✔
732
                State:               db.CommitmentStatePlanned,
2✔
733
                CreationContextJSON: json.RawMessage(buf),
2✔
734
        }
2✔
735

2✔
736
        err = tx.Insert(&dbRenewedCommitment)
2✔
737
        if respondwith.ErrorText(w, err) {
2✔
738
                return
×
739
        }
×
740

741
        renewContext := db.CommitmentWorkflowContext{
2✔
742
                Reason:                 db.CommitmentReasonRenew,
2✔
743
                RelatedCommitmentIDs:   []db.ProjectCommitmentID{dbRenewedCommitment.ID},
2✔
744
                RelatedCommitmentUUIDs: []db.ProjectCommitmentUUID{dbRenewedCommitment.UUID},
2✔
745
        }
2✔
746
        buf, err = json.Marshal(renewContext)
2✔
747
        if respondwith.ErrorText(w, err) {
2✔
748
                return
×
749
        }
×
750
        dbCommitment.RenewContextJSON = Some(json.RawMessage(buf))
2✔
751
        _, err = tx.Update(&dbCommitment)
2✔
752
        if respondwith.ErrorText(w, err) {
2✔
753
                return
×
754
        }
×
755

756
        err = tx.Commit()
2✔
757
        if respondwith.ErrorText(w, err) {
2✔
758
                return
×
759
        }
×
760

761
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
2✔
762
        if respondwith.ErrorText(w, err) {
2✔
NEW
763
                return
×
NEW
764
        }
×
765
        serviceInfo, ok := maybeServiceInfo.Unpack()
2✔
766
        if !ok {
2✔
NEW
767
                http.Error(w, "service not found", http.StatusNotFound)
×
NEW
768
                return
×
NEW
769
        }
×
770
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
2✔
771

2✔
772
        // Create resultset and auditlogs
2✔
773
        c := p.convertCommitmentToDisplayForm(dbRenewedCommitment, loc, token, resourceInfo.Unit)
2✔
774
        auditEvent := commitmentEventTarget{
2✔
775
                DomainID:        dbDomain.UUID,
2✔
776
                DomainName:      dbDomain.Name,
2✔
777
                ProjectID:       dbProject.UUID,
2✔
778
                ProjectName:     dbProject.Name,
2✔
779
                Commitments:     []limesresources.Commitment{c},
2✔
780
                WorkflowContext: Some(creationContext),
2✔
781
        }
2✔
782

2✔
783
        p.auditor.Record(audittools.Event{
2✔
784
                Time:       p.timeNow(),
2✔
785
                Request:    r,
2✔
786
                User:       token,
2✔
787
                ReasonCode: http.StatusAccepted,
2✔
788
                Action:     cadf.UpdateAction,
2✔
789
                Target:     auditEvent,
2✔
790
        })
2✔
791

2✔
792
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
2✔
793
}
794

795
// DeleteProjectCommitment handles DELETE /v1/domains/:domain_id/projects/:project_id/commitments/:id.
796
func (p *v1Provider) DeleteProjectCommitment(w http.ResponseWriter, r *http.Request) {
8✔
797
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/:id")
8✔
798
        token := p.CheckToken(r)
8✔
799
        if !token.Require(w, "project:edit") { // NOTE: There is a more specific AuthZ check further down below.
8✔
800
                return
×
801
        }
×
802
        dbDomain := p.FindDomainFromRequest(w, r)
8✔
803
        if dbDomain == nil {
9✔
804
                return
1✔
805
        }
1✔
806
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
7✔
807
        if dbProject == nil {
8✔
808
                return
1✔
809
        }
1✔
810

811
        // load commitment
812
        var dbCommitment db.ProjectCommitment
6✔
813
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, mux.Vars(r)["id"], dbProject.ID)
6✔
814
        if errors.Is(err, sql.ErrNoRows) {
7✔
815
                http.Error(w, "no such commitment", http.StatusNotFound)
1✔
816
                return
1✔
817
        } else if respondwith.ErrorText(w, err) {
6✔
818
                return
×
819
        }
×
820
        var loc core.AZResourceLocation
5✔
821
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
5✔
822
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
5✔
823
        if errors.Is(err, sql.ErrNoRows) {
5✔
824
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
825
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
826
                return
×
827
        } else if respondwith.ErrorText(w, err) {
5✔
828
                return
×
829
        }
×
830

831
        // check authorization for this specific commitment
832
        if !p.canDeleteCommitment(token, dbCommitment) {
6✔
833
                http.Error(w, "Forbidden", http.StatusForbidden)
1✔
834
                return
1✔
835
        }
1✔
836

837
        // perform deletion
838
        _, err = p.DB.Delete(&dbCommitment)
4✔
839
        if respondwith.ErrorText(w, err) {
4✔
840
                return
×
841
        }
×
842
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
4✔
843
        if respondwith.ErrorText(w, err) {
4✔
NEW
844
                return
×
NEW
845
        }
×
846
        serviceInfo, ok := maybeServiceInfo.Unpack()
4✔
847
        if !ok {
4✔
NEW
848
                http.Error(w, "service not found", http.StatusNotFound)
×
NEW
849
                return
×
NEW
850
        }
×
851
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
4✔
852
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token, resourceInfo.Unit)
4✔
853
        p.auditor.Record(audittools.Event{
4✔
854
                Time:       p.timeNow(),
4✔
855
                Request:    r,
4✔
856
                User:       token,
4✔
857
                ReasonCode: http.StatusNoContent,
4✔
858
                Action:     cadf.DeleteAction,
4✔
859
                Target: commitmentEventTarget{
4✔
860
                        DomainID:    dbDomain.UUID,
4✔
861
                        DomainName:  dbDomain.Name,
4✔
862
                        ProjectID:   dbProject.UUID,
4✔
863
                        ProjectName: dbProject.Name,
4✔
864
                        Commitments: []limesresources.Commitment{c},
4✔
865
                },
4✔
866
        })
4✔
867

4✔
868
        w.WriteHeader(http.StatusNoContent)
4✔
869
}
870

871
func (p *v1Provider) canDeleteCommitment(token *gopherpolicy.Token, commitment db.ProjectCommitment) bool {
64✔
872
        // up to 24 hours after creation of fresh commitments, future commitments can still be deleted by their creators
64✔
873
        if commitment.State == db.CommitmentStatePlanned || commitment.State == db.CommitmentStatePending || commitment.State == db.CommitmentStateActive {
128✔
874
                var creationContext db.CommitmentWorkflowContext
64✔
875
                err := json.Unmarshal(commitment.CreationContextJSON, &creationContext)
64✔
876
                if err == nil && creationContext.Reason == db.CommitmentReasonCreate && p.timeNow().Before(commitment.CreatedAt.Add(24*time.Hour)) {
104✔
877
                        if token.Check("project:edit") {
80✔
878
                                return true
40✔
879
                        }
40✔
880
                }
881
        }
882

883
        // afterwards, a more specific permission is required to delete it
884
        //
885
        // This protects cloud admins making capacity planning decisions based on future commitments
886
        // from having their forecasts ruined by project admins suffering from buyer's remorse.
887
        return token.Check("project:uncommit")
24✔
888
}
889

890
// StartCommitmentTransfer handles POST /v1/domains/:id/projects/:id/commitments/:id/start-transfer
891
func (p *v1Provider) StartCommitmentTransfer(w http.ResponseWriter, r *http.Request) {
8✔
892
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/:id/start-transfer")
8✔
893
        token := p.CheckToken(r)
8✔
894
        if !token.Require(w, "project:edit") {
8✔
895
                return
×
896
        }
×
897
        dbDomain := p.FindDomainFromRequest(w, r)
8✔
898
        if dbDomain == nil {
8✔
899
                return
×
900
        }
×
901
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
8✔
902
        if dbProject == nil {
8✔
903
                return
×
904
        }
×
905
        // TODO: eventually migrate this struct into go-api-declarations
906
        var parseTarget struct {
8✔
907
                Request struct {
8✔
908
                        Amount         uint64                                  `json:"amount"`
8✔
909
                        TransferStatus limesresources.CommitmentTransferStatus `json:"transfer_status,omitempty"`
8✔
910
                } `json:"commitment"`
8✔
911
        }
8✔
912
        if !RequireJSON(w, r, &parseTarget) {
8✔
913
                return
×
914
        }
×
915
        req := parseTarget.Request
8✔
916

8✔
917
        if req.TransferStatus != limesresources.CommitmentTransferStatusUnlisted && req.TransferStatus != limesresources.CommitmentTransferStatusPublic {
8✔
918
                http.Error(w, fmt.Sprintf("Invalid transfer_status code. Must be %s or %s.", limesresources.CommitmentTransferStatusUnlisted, limesresources.CommitmentTransferStatusPublic), http.StatusBadRequest)
×
919
                return
×
920
        }
×
921

922
        if req.Amount <= 0 {
9✔
923
                http.Error(w, "delivered amount needs to be a positive value.", http.StatusBadRequest)
1✔
924
                return
1✔
925
        }
1✔
926

927
        // load commitment
928
        var dbCommitment db.ProjectCommitment
7✔
929
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, mux.Vars(r)["id"], dbProject.ID)
7✔
930
        if errors.Is(err, sql.ErrNoRows) {
7✔
931
                http.Error(w, "no such commitment", http.StatusNotFound)
×
932
                return
×
933
        } else if respondwith.ErrorText(w, err) {
7✔
934
                return
×
935
        }
×
936

937
        // Mark whole commitment or a newly created, splitted one as transferrable.
938
        tx, err := p.DB.Begin()
7✔
939
        if respondwith.ErrorText(w, err) {
7✔
940
                return
×
941
        }
×
942
        defer sqlext.RollbackUnlessCommitted(tx)
7✔
943
        transferToken := p.generateTransferToken()
7✔
944

7✔
945
        // Deny requests with a greater amount than the commitment.
7✔
946
        if req.Amount > dbCommitment.Amount {
8✔
947
                http.Error(w, "delivered amount exceeds the commitment amount.", http.StatusBadRequest)
1✔
948
                return
1✔
949
        }
1✔
950

951
        if req.Amount == dbCommitment.Amount {
10✔
952
                dbCommitment.TransferStatus = req.TransferStatus
4✔
953
                dbCommitment.TransferToken = Some(transferToken)
4✔
954
                _, err = tx.Update(&dbCommitment)
4✔
955
                if respondwith.ErrorText(w, err) {
4✔
956
                        return
×
957
                }
×
958
        } else {
2✔
959
                now := p.timeNow()
2✔
960
                transferAmount := req.Amount
2✔
961
                remainingAmount := dbCommitment.Amount - req.Amount
2✔
962
                transferCommitment, err := p.buildSplitCommitment(dbCommitment, transferAmount)
2✔
963
                if respondwith.ErrorText(w, err) {
2✔
964
                        return
×
965
                }
×
966
                transferCommitment.TransferStatus = req.TransferStatus
2✔
967
                transferCommitment.TransferToken = Some(transferToken)
2✔
968
                remainingCommitment, err := p.buildSplitCommitment(dbCommitment, remainingAmount)
2✔
969
                if respondwith.ErrorText(w, err) {
2✔
970
                        return
×
971
                }
×
972
                err = tx.Insert(&transferCommitment)
2✔
973
                if respondwith.ErrorText(w, err) {
2✔
974
                        return
×
975
                }
×
976
                err = tx.Insert(&remainingCommitment)
2✔
977
                if respondwith.ErrorText(w, err) {
2✔
978
                        return
×
979
                }
×
980
                supersedeContext := db.CommitmentWorkflowContext{
2✔
981
                        Reason:                 db.CommitmentReasonSplit,
2✔
982
                        RelatedCommitmentIDs:   []db.ProjectCommitmentID{transferCommitment.ID, remainingCommitment.ID},
2✔
983
                        RelatedCommitmentUUIDs: []db.ProjectCommitmentUUID{transferCommitment.UUID, remainingCommitment.UUID},
2✔
984
                }
2✔
985
                buf, err := json.Marshal(supersedeContext)
2✔
986
                if respondwith.ErrorText(w, err) {
2✔
987
                        return
×
988
                }
×
989
                dbCommitment.State = db.CommitmentStateSuperseded
2✔
990
                dbCommitment.SupersededAt = Some(now)
2✔
991
                dbCommitment.SupersedeContextJSON = Some(json.RawMessage(buf))
2✔
992
                _, err = tx.Update(&dbCommitment)
2✔
993
                if respondwith.ErrorText(w, err) {
2✔
994
                        return
×
995
                }
×
996
                dbCommitment = transferCommitment
2✔
997
        }
998
        err = tx.Commit()
6✔
999
        if respondwith.ErrorText(w, err) {
6✔
1000
                return
×
1001
        }
×
1002

1003
        var loc core.AZResourceLocation
6✔
1004
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
6✔
1005
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
6✔
1006
        if errors.Is(err, sql.ErrNoRows) {
6✔
1007
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
1008
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
1009
                return
×
1010
        } else if respondwith.ErrorText(w, err) {
6✔
1011
                return
×
1012
        }
×
1013

1014
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
6✔
1015
        if respondwith.ErrorText(w, err) {
6✔
NEW
1016
                return
×
NEW
1017
        }
×
1018
        serviceInfo, ok := maybeServiceInfo.Unpack()
6✔
1019
        if !ok {
6✔
NEW
1020
                http.Error(w, "service not found", http.StatusNotFound)
×
NEW
1021
                return
×
NEW
1022
        }
×
1023
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
6✔
1024
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token, resourceInfo.Unit)
6✔
1025
        if respondwith.ErrorText(w, err) {
6✔
NEW
1026
                return
×
NEW
1027
        }
×
1028
        p.auditor.Record(audittools.Event{
6✔
1029
                Time:       p.timeNow(),
6✔
1030
                Request:    r,
6✔
1031
                User:       token,
6✔
1032
                ReasonCode: http.StatusAccepted,
6✔
1033
                Action:     cadf.UpdateAction,
6✔
1034
                Target: commitmentEventTarget{
6✔
1035
                        DomainID:    dbDomain.UUID,
6✔
1036
                        DomainName:  dbDomain.Name,
6✔
1037
                        ProjectID:   dbProject.UUID,
6✔
1038
                        ProjectName: dbProject.Name,
6✔
1039
                        Commitments: []limesresources.Commitment{c},
6✔
1040
                },
6✔
1041
        })
6✔
1042
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
6✔
1043
}
1044

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

1072
func (p *v1Provider) buildConvertedCommitment(dbCommitment db.ProjectCommitment, azResourceID db.ProjectAZResourceID, amount uint64) (db.ProjectCommitment, error) {
2✔
1073
        now := p.timeNow()
2✔
1074
        creationContext := db.CommitmentWorkflowContext{
2✔
1075
                Reason:                 db.CommitmentReasonConvert,
2✔
1076
                RelatedCommitmentIDs:   []db.ProjectCommitmentID{dbCommitment.ID},
2✔
1077
                RelatedCommitmentUUIDs: []db.ProjectCommitmentUUID{dbCommitment.UUID},
2✔
1078
        }
2✔
1079
        buf, err := json.Marshal(creationContext)
2✔
1080
        if err != nil {
2✔
1081
                return db.ProjectCommitment{}, err
×
1082
        }
×
1083
        return db.ProjectCommitment{
2✔
1084
                UUID:                p.generateProjectCommitmentUUID(),
2✔
1085
                AZResourceID:        azResourceID,
2✔
1086
                Amount:              amount,
2✔
1087
                Duration:            dbCommitment.Duration,
2✔
1088
                CreatedAt:           now,
2✔
1089
                CreatorUUID:         dbCommitment.CreatorUUID,
2✔
1090
                CreatorName:         dbCommitment.CreatorName,
2✔
1091
                ConfirmBy:           dbCommitment.ConfirmBy,
2✔
1092
                ConfirmedAt:         dbCommitment.ConfirmedAt,
2✔
1093
                ExpiresAt:           dbCommitment.ExpiresAt,
2✔
1094
                CreationContextJSON: json.RawMessage(buf),
2✔
1095
                State:               dbCommitment.State,
2✔
1096
        }, nil
2✔
1097
}
1098

1099
// GetCommitmentByTransferToken handles GET /v1/commitments/{token}
1100
func (p *v1Provider) GetCommitmentByTransferToken(w http.ResponseWriter, r *http.Request) {
2✔
1101
        httpapi.IdentifyEndpoint(r, "/v1/commitments/:token")
2✔
1102
        token := p.CheckToken(r)
2✔
1103
        if !token.Require(w, "cluster:show_basic") {
2✔
1104
                return
×
1105
        }
×
1106
        transferToken := mux.Vars(r)["token"]
2✔
1107

2✔
1108
        // The token column is a unique key, so we expect only one result.
2✔
1109
        var dbCommitment db.ProjectCommitment
2✔
1110
        err := p.DB.SelectOne(&dbCommitment, findCommitmentByTransferToken, transferToken)
2✔
1111
        if errors.Is(err, sql.ErrNoRows) {
3✔
1112
                http.Error(w, "no matching commitment found.", http.StatusNotFound)
1✔
1113
                return
1✔
1114
        } else if respondwith.ErrorText(w, err) {
2✔
1115
                return
×
1116
        }
×
1117

1118
        var loc core.AZResourceLocation
1✔
1119
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
1✔
1120
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
1✔
1121
        if errors.Is(err, sql.ErrNoRows) {
1✔
1122
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
1123
                http.Error(w, "location data not found.", http.StatusNotFound)
×
1124
                return
×
1125
        } else if respondwith.ErrorText(w, err) {
1✔
1126
                return
×
1127
        }
×
1128

1129
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
1✔
1130
        if respondwith.ErrorText(w, err) {
1✔
NEW
1131
                return
×
NEW
1132
        }
×
1133
        serviceInfo, ok := maybeServiceInfo.Unpack()
1✔
1134
        if !ok {
1✔
NEW
1135
                http.Error(w, "service not found", http.StatusNotFound)
×
NEW
1136
                return
×
NEW
1137
        }
×
1138
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
1✔
1139
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token, resourceInfo.Unit)
1✔
1140
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
1✔
1141
}
1142

1143
// TransferCommitment handles POST /v1/domains/{domain_id}/projects/{project_id}/transfer-commitment/{id}?token={token}
1144
func (p *v1Provider) TransferCommitment(w http.ResponseWriter, r *http.Request) {
5✔
1145
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/transfer-commitment/:id")
5✔
1146
        token := p.CheckToken(r)
5✔
1147
        if !token.Require(w, "project:edit") {
5✔
1148
                return
×
1149
        }
×
1150
        transferToken := r.Header.Get("Transfer-Token")
5✔
1151
        if transferToken == "" {
6✔
1152
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
1✔
1153
                return
1✔
1154
        }
1✔
1155
        commitmentID := mux.Vars(r)["id"]
4✔
1156
        if commitmentID == "" {
4✔
1157
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
×
1158
                return
×
1159
        }
×
1160
        dbDomain := p.FindDomainFromRequest(w, r)
4✔
1161
        if dbDomain == nil {
4✔
1162
                return
×
1163
        }
×
1164
        targetProject := p.FindProjectFromRequest(w, r, dbDomain)
4✔
1165
        if targetProject == nil {
4✔
1166
                return
×
1167
        }
×
1168

1169
        // find commitment by transfer_token
1170
        var dbCommitment db.ProjectCommitment
4✔
1171
        err := p.DB.SelectOne(&dbCommitment, getCommitmentWithMatchingTransferTokenQuery, commitmentID, transferToken)
4✔
1172
        if errors.Is(err, sql.ErrNoRows) {
5✔
1173
                http.Error(w, "no matching commitment found", http.StatusNotFound)
1✔
1174
                return
1✔
1175
        } else if respondwith.ErrorText(w, err) {
4✔
1176
                return
×
1177
        }
×
1178

1179
        var loc core.AZResourceLocation
3✔
1180
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
3✔
1181
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
3✔
1182
        if errors.Is(err, sql.ErrNoRows) {
3✔
1183
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
1184
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
1185
                return
×
1186
        } else if respondwith.ErrorText(w, err) {
3✔
1187
                return
×
1188
        }
×
1189

1190
        // get target service and AZ resource
1191
        var (
3✔
1192
                sourceResourceID   db.ProjectResourceID
3✔
1193
                targetResourceID   db.ProjectResourceID
3✔
1194
                targetAZResourceID db.ProjectAZResourceID
3✔
1195
        )
3✔
1196
        err = p.DB.QueryRow(findTargetAZResourceIDBySourceIDQuery, dbCommitment.AZResourceID, targetProject.ID).
3✔
1197
                Scan(&sourceResourceID, &targetResourceID, &targetAZResourceID)
3✔
1198
        if respondwith.ErrorText(w, err) {
3✔
1199
                return
×
1200
        }
×
1201

1202
        // validate that we have enough committable capacity on the receiving side
1203
        tx, err := p.DB.Begin()
3✔
1204
        if respondwith.ErrorText(w, err) {
3✔
1205
                return
×
1206
        }
×
1207
        defer sqlext.RollbackUnlessCommitted(tx)
3✔
1208
        ok, err := datamodel.CanMoveExistingCommitment(dbCommitment.Amount, loc, sourceResourceID, targetResourceID, p.Cluster, tx)
3✔
1209
        if respondwith.ErrorText(w, err) {
3✔
1210
                return
×
1211
        }
×
1212
        if !ok {
4✔
1213
                http.Error(w, "not enough committable capacity on the receiving side", http.StatusConflict)
1✔
1214
                return
1✔
1215
        }
1✔
1216

1217
        dbCommitment.TransferStatus = ""
2✔
1218
        dbCommitment.TransferToken = None[string]()
2✔
1219
        dbCommitment.AZResourceID = targetAZResourceID
2✔
1220
        _, err = tx.Update(&dbCommitment)
2✔
1221
        if respondwith.ErrorText(w, err) {
2✔
1222
                return
×
1223
        }
×
1224
        err = tx.Commit()
2✔
1225
        if respondwith.ErrorText(w, err) {
2✔
1226
                return
×
1227
        }
×
1228

1229
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
2✔
1230
        if respondwith.ErrorText(w, err) {
2✔
NEW
1231
                return
×
NEW
1232
        }
×
1233
        serviceInfo, ok := maybeServiceInfo.Unpack()
2✔
1234
        if !ok {
2✔
NEW
1235
                http.Error(w, "service not found", http.StatusNotFound)
×
NEW
1236
                return
×
NEW
1237
        }
×
1238
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
2✔
1239
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token, resourceInfo.Unit)
2✔
1240
        p.auditor.Record(audittools.Event{
2✔
1241
                Time:       p.timeNow(),
2✔
1242
                Request:    r,
2✔
1243
                User:       token,
2✔
1244
                ReasonCode: http.StatusAccepted,
2✔
1245
                Action:     cadf.UpdateAction,
2✔
1246
                Target: commitmentEventTarget{
2✔
1247
                        DomainID:    dbDomain.UUID,
2✔
1248
                        DomainName:  dbDomain.Name,
2✔
1249
                        ProjectID:   targetProject.UUID,
2✔
1250
                        ProjectName: targetProject.Name,
2✔
1251
                        Commitments: []limesresources.Commitment{c},
2✔
1252
                },
2✔
1253
        })
2✔
1254

2✔
1255
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
2✔
1256
}
1257

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

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

1276
        // validate request
1277
        vars := mux.Vars(r)
2✔
1278
        serviceInfos, err := p.Cluster.AllServiceInfos()
2✔
1279
        if respondwith.ErrorText(w, err) {
2✔
NEW
1280
                return
×
NEW
1281
        }
×
1282

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

2✔
1295
        serviceInfo := core.InfoForService(serviceInfos, sourceServiceType)
2✔
1296
        sourceResInfo := core.InfoForResource(serviceInfo, sourceResourceName)
2✔
1297

2✔
1298
        // enumerate possible conversions
2✔
1299
        conversions := make([]limesresources.CommitmentConversionRule, 0)
2✔
1300
        if sourceBehavior.ConversionRule.IsSome() {
4✔
1301
                for _, targetServiceType := range slices.Sorted(maps.Keys(serviceInfos)) {
8✔
1302
                        for targetResourceName, targetResInfo := range serviceInfos[targetServiceType].Resources {
26✔
1303
                                if sourceServiceType == targetServiceType && sourceResourceName == targetResourceName {
22✔
1304
                                        continue
2✔
1305
                                }
1306
                                if sourceResInfo.Unit != targetResInfo.Unit {
28✔
1307
                                        continue
10✔
1308
                                }
1309

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

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

1335
        respondwith.JSON(w, http.StatusOK, map[string]any{"conversions": conversions})
2✔
1336
}
1337

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

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

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

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

1440
        tx, err := p.DB.Begin()
3✔
1441
        if respondwith.ErrorText(w, err) {
3✔
1442
                return
×
1443
        }
×
1444
        defer sqlext.RollbackUnlessCommitted(tx)
3✔
1445

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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